a tool for shared writing and social publishing

Feature/accounts (#99)

* WIP

* add migration for email tokens

* add basic logout flow

* added styling for log in popover

* some styling to the log in button on home

* super minimal add doc to homepage

* added asChild prop to popover, tweaked spacing in mobile footer on home

* added a settings popover for logged in users that contains log out button, streamlined the menu

* smal tweaks

* add errors states

* sync local docs on login

* use fixed values to determine leaflet preview size

* fetch data for persisted docs

* removed create from mobile footer in doc, fixed some small styling things inthe leaflet preview

* flip order of migration add_more_get_fact_functions

* add leaflets even if logging in to existing account

* fix login adding existing docs and swr leaflet dat

* positioned the warning banner on logged out home

* add height to home page layout on mobile

* styling the logged out banner to include the log in button and to appear in the right place on desktop

* made bg of InputWithLabel transparent

* overlay link on leaflet preview instead of nesting

* move loggedOutWarning to seperate component and check leaflets

* send login code with postmark

* make removing leaflets from home work

* import mutate once

* add action file oops

* make remove from home work!

---------

Co-authored-by: celine <celine@hyperlink.academy>
Co-authored-by: Brendan Schlagel <brendan.schlagel@gmail.com>

authored by awarm.space celine Brendan Schlagel and committed by GitHub c3ff21e2 779b8782

+21 -1
actions/createNewLeaflet.ts
··· 3 3 import { drizzle } from "drizzle-orm/postgres-js"; 4 4 import { 5 5 entities, 6 + identities, 6 7 permission_tokens, 7 8 permission_token_rights, 8 9 entity_sets, 9 10 facts, 11 + permission_token_on_homepage, 12 + email_auth_tokens, 10 13 } from "drizzle/schema"; 11 14 import { redirect } from "next/navigation"; 12 15 import postgres from "postgres"; 13 16 import { v7 } from "uuid"; 14 - import { sql } from "drizzle-orm"; 17 + import { sql, eq, and } from "drizzle-orm"; 18 + import { cookies } from "next/headers"; 15 19 16 20 export async function createNewLeaflet( 17 21 pageType: "canvas" | "doc", 18 22 redirectUser: boolean, 19 23 ) { 20 24 const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 25 + let auth_token = cookies().get("auth_token")?.value; 21 26 const db = drizzle(client); 22 27 let { permissionToken } = await db.transaction(async (tx) => { 23 28 // Create a new entity set ··· 115 120 data: sql`${{ type: "number", value: 1 }}::jsonb`, 116 121 }, 117 122 ]); 123 + } 124 + if (auth_token) { 125 + await tx.execute(sql` 126 + WITH auth_token AS ( 127 + SELECT identities.id as identity_id 128 + FROM email_auth_tokens 129 + LEFT JOIN identities ON email_auth_tokens.identity = identities.id 130 + WHERE email_auth_tokens.id = ${auth_token} 131 + AND email_auth_tokens.confirmed = true 132 + AND identities.id IS NOT NULL 133 + ) 134 + INSERT INTO permission_token_on_homepage (token, identity) 135 + SELECT ${permissionToken.id}, identity_id 136 + FROM auth_token 137 + `); 118 138 } 119 139 120 140 return { permissionToken, rights, root_entity, entity_set };
+103
actions/emailAuth.ts
··· 1 + "use server"; 2 + 3 + import { randomBytes } from "crypto"; 4 + import { drizzle } from "drizzle-orm/postgres-js"; 5 + import postgres from "postgres"; 6 + import { email_auth_tokens, identities } from "drizzle/schema"; 7 + import { and, eq } from "drizzle-orm"; 8 + import { cookies } from "next/headers"; 9 + 10 + async function sendAuthCode(email: string, code: string) { 11 + let res = await fetch("https://api.postmarkapp.com/email", { 12 + method: "POST", 13 + headers: { 14 + "Content-Type": "application/json", 15 + "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, 16 + }, 17 + body: JSON.stringify({ 18 + From: "Leaflet <accounts@leaflet.pub>", 19 + Subject: `Your authentication code for Leaflet is ${code}`, 20 + To: email, 21 + TextBody: `Paste this code to login to Leaflet: 22 + 23 + ${code} 24 + `, 25 + }), 26 + }); 27 + } 28 + 29 + export async function requestAuthEmailToken(email: string) { 30 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 31 + const db = drizzle(client); 32 + 33 + const code = randomBytes(3).toString("hex").toUpperCase(); 34 + 35 + const [token] = await db 36 + .insert(email_auth_tokens) 37 + .values({ 38 + email, 39 + confirmation_code: code, 40 + confirmed: false, 41 + }) 42 + .returning({ 43 + id: email_auth_tokens.id, 44 + }); 45 + 46 + await sendAuthCode(email, code); 47 + 48 + client.end(); 49 + return token.id; 50 + } 51 + 52 + export async function confirmEmailAuthToken(tokenId: string, code: string) { 53 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 54 + const db = drizzle(client); 55 + 56 + const [token] = await db 57 + .select() 58 + .from(email_auth_tokens) 59 + .where(eq(email_auth_tokens.id, tokenId)); 60 + 61 + if (!token) { 62 + client.end(); 63 + return null; 64 + } 65 + 66 + if (token.confirmation_code !== code) { 67 + client.end(); 68 + return null; 69 + } 70 + 71 + if (token.confirmed) { 72 + client.end(); 73 + return null; 74 + } 75 + 76 + let [identity] = await db 77 + .select() 78 + .from(identities) 79 + .where(eq(identities.email, token.email)); 80 + 81 + const [confirmedToken] = await db 82 + .update(email_auth_tokens) 83 + .set({ 84 + confirmed: true, 85 + identity: identity?.id, 86 + }) 87 + .where( 88 + and( 89 + eq(email_auth_tokens.id, tokenId), 90 + eq(email_auth_tokens.confirmation_code, code), 91 + ), 92 + ) 93 + .returning(); 94 + 95 + cookies().set("auth_token", confirmedToken.id, { 96 + secure: process.env.NODE_ENV === "production", 97 + httpOnly: true, 98 + sameSite: "strict", 99 + }); 100 + 101 + client.end(); 102 + return confirmedToken; 103 + }
+32
actions/getIdentityData.ts
··· 1 + "use server"; 2 + 3 + import { createServerClient } from "@supabase/ssr"; 4 + import { cookies } from "next/headers"; 5 + import { Database } from "supabase/database.types"; 6 + 7 + let supabase = createServerClient<Database>( 8 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 9 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 10 + { cookies: {} }, 11 + ); 12 + export async function getIdentityData() { 13 + let cookieStore = cookies(); 14 + let auth_token = cookieStore.get("auth_token")?.value; 15 + let auth_res = auth_token 16 + ? await supabase 17 + .from("email_auth_tokens") 18 + .select( 19 + `*, 20 + identities( 21 + *, 22 + home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), 23 + permission_token_on_homepage(created_at, permission_tokens!inner(*, permission_token_rights(*))) 24 + )`, 25 + ) 26 + .eq("id", auth_token) 27 + .eq("confirmed", true) 28 + .single() 29 + : null; 30 + if (!auth_res?.data?.identities) return null; 31 + return auth_res.data.identities; 32 + }
+31
actions/getLeafletData.ts
··· 1 + "use server"; 2 + 3 + import { createServerClient } from "@supabase/ssr"; 4 + import { Fact } from "src/replicache"; 5 + import { Attributes } from "src/replicache/attributes"; 6 + import { Database } from "supabase/database.types"; 7 + 8 + let supabase = createServerClient<Database>( 9 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 10 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 11 + { cookies: {} }, 12 + ); 13 + export async function getLeafletData(tokens: string[]) { 14 + //Eventually check permission tokens in here somehow! 15 + let all_facts = await supabase.rpc("get_facts_for_roots", { 16 + max_depth: 3, 17 + roots: tokens, 18 + }); 19 + if (all_facts.data) 20 + return all_facts.data.reduce( 21 + (acc, fact) => { 22 + if (!acc[fact.root_id]) acc[fact.root_id] = []; 23 + acc[fact.root_id].push( 24 + fact as unknown as Fact<keyof typeof Attributes>, 25 + ); 26 + return acc; 27 + }, 28 + {} as { [key: string]: Fact<keyof typeof Attributes>[] }, 29 + ); 30 + return {}; 31 + }
+131
actions/login.ts
··· 1 + "use server"; 2 + import { drizzle } from "drizzle-orm/postgres-js"; 3 + import postgres from "postgres"; 4 + import { 5 + email_auth_tokens, 6 + identities, 7 + entity_sets, 8 + entities, 9 + permission_tokens, 10 + permission_token_rights, 11 + permission_token_on_homepage, 12 + } from "drizzle/schema"; 13 + import { and, eq, isNull } from "drizzle-orm"; 14 + import { cookies } from "next/headers"; 15 + import { redirect } from "next/navigation"; 16 + import { v7 } from "uuid"; 17 + 18 + export async function loginWithEmailToken( 19 + localLeaflets: { token: { id: string }; added_at: string }[], 20 + ) { 21 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 22 + const db = drizzle(client); 23 + let token_id = cookies().get("auth_token")?.value; 24 + if (!token_id) return null; 25 + let result = await db.transaction(async (tx) => { 26 + let [token] = await tx 27 + .select() 28 + .from(email_auth_tokens) 29 + .where( 30 + and( 31 + eq(email_auth_tokens.id, token_id), 32 + eq(email_auth_tokens.confirmed, true), 33 + ), 34 + ); 35 + if (!token) return null; 36 + if (token.identity) { 37 + let id = token.identity; 38 + if (localLeaflets.length > 0) 39 + await tx 40 + .insert(permission_token_on_homepage) 41 + .values( 42 + localLeaflets.map((l) => ({ 43 + identity: id, 44 + token: l.token.id, 45 + })), 46 + ) 47 + .onConflictDoNothing(); 48 + return token; 49 + } 50 + let [existingIdentity] = await tx 51 + .select() 52 + .from(identities) 53 + .where(eq(identities.email, token.email)); 54 + 55 + let identity = existingIdentity; 56 + if (!existingIdentity) { 57 + let identityCookie = cookies().get("identity"); 58 + if (identityCookie) { 59 + let [existingIdentityFromCookie] = await tx 60 + .select() 61 + .from(identities) 62 + .where( 63 + and( 64 + eq(identities.id, identityCookie.value), 65 + isNull(identities.email), 66 + ), 67 + ); 68 + if (existingIdentityFromCookie) { 69 + await tx 70 + .update(identities) 71 + .set({ email: token.email }) 72 + .where(eq(identities.id, existingIdentityFromCookie.id)); 73 + identity = existingIdentityFromCookie; 74 + } 75 + } else { 76 + // Create a new entity set 77 + let [entity_set] = await tx.insert(entity_sets).values({}).returning(); 78 + // Create a root-entity 79 + let [entity] = await tx 80 + .insert(entities) 81 + // And add it to that permission set 82 + .values({ set: entity_set.id, id: v7() }) 83 + .returning(); 84 + //Create a new permission token 85 + let [permissionToken] = await tx 86 + .insert(permission_tokens) 87 + .values({ root_entity: entity.id }) 88 + .returning(); 89 + //and give it all the permission on that entity set 90 + let [rights] = await tx 91 + .insert(permission_token_rights) 92 + .values({ 93 + token: permissionToken.id, 94 + entity_set: entity_set.id, 95 + read: true, 96 + write: true, 97 + create_token: true, 98 + change_entity_set: true, 99 + }) 100 + .returning(); 101 + let [newIdentity] = await tx 102 + .insert(identities) 103 + .values({ 104 + home_page: permissionToken.id, 105 + email: token.email, 106 + }) 107 + .returning(); 108 + identity = newIdentity; 109 + } 110 + } 111 + 112 + await tx 113 + .update(email_auth_tokens) 114 + .set({ identity: identity.id }) 115 + .where(eq(email_auth_tokens.id, token_id)); 116 + 117 + console.log( 118 + await tx.insert(permission_token_on_homepage).values( 119 + localLeaflets.map((l) => ({ 120 + identity: identity.id, 121 + token: l.token.id, 122 + })), 123 + ), 124 + ); 125 + 126 + return token; 127 + }); 128 + 129 + client.end(); 130 + redirect("/home"); 131 + }
+8
actions/logout.ts
··· 1 + "use server"; 2 + 3 + import { cookies } from "next/headers"; 4 + 5 + export async function logout() { 6 + cookies().delete("auth_token"); 7 + cookies().delete("identity"); 8 + }
+29
actions/removeLeafletFromHome.ts
··· 1 + "use server"; 2 + 3 + import { drizzle } from "drizzle-orm/postgres-js"; 4 + import { permission_token_on_homepage } from "drizzle/schema"; 5 + import postgres from "postgres"; 6 + import { v7 } from "uuid"; 7 + import { sql, eq, inArray, and } from "drizzle-orm"; 8 + import { cookies } from "next/headers"; 9 + import { getIdentityData } from "./getIdentityData"; 10 + 11 + export async function removeLeafletFromHome(tokens: string[]) { 12 + const identity = await getIdentityData(); 13 + if (!identity) return null; 14 + 15 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 16 + const db = drizzle(client); 17 + 18 + await db 19 + .delete(permission_token_on_homepage) 20 + .where( 21 + and( 22 + eq(permission_token_on_homepage.identity, identity.id), 23 + inArray(permission_token_on_homepage.token, tokens), 24 + ), 25 + ); 26 + 27 + client.end(); 28 + return true; 29 + }
+10 -1
app/globals.css
··· 180 180 @apply px-1; 181 181 @apply py-0.5; 182 182 @apply hover:border-tertiary; 183 - @apply active:border-tertiary; 183 + @apply focus:border-tertiary; 184 + @apply focus:outline; 185 + @apply focus:outline-tertiary; 186 + @apply focus:outline-2; 187 + @apply focus:outline-offset-1; 188 + @apply focus-within:border-tertiary; 189 + @apply focus-within:outline; 190 + @apply focus-within:outline-tertiary; 191 + @apply focus-within:outline-2; 192 + @apply focus-within:outline-offset-1; 184 193 @apply disabled:border-border-light; 185 194 @apply disabled:bg-border-light; 186 195 @apply disabled:text-tertiary;
+34
app/home/AccountSettings.tsx
··· 1 + "use client"; 2 + 3 + import { HoverButton } from "components/Buttons"; 4 + import { AccountSmall, LogoutSmall } from "components/Icons"; 5 + import { Menu, MenuItem } from "components/Layout"; 6 + import { logout } from "actions/logout"; 7 + import { mutate } from "swr"; 8 + 9 + // it was going have a popover with a log out button 10 + export const AccountSettings = () => { 11 + return ( 12 + <Menu 13 + trigger={ 14 + <HoverButton 15 + icon=<AccountSmall /> 16 + label="Settings" 17 + noLabelOnMobile 18 + background="bg-accent-1" 19 + text="text-accent-2" 20 + /> 21 + } 22 + > 23 + <MenuItem 24 + onSelect={async () => { 25 + await logout(); 26 + mutate("identity"); 27 + }} 28 + > 29 + <LogoutSmall /> 30 + Logout 31 + </MenuItem> 32 + </Menu> 33 + ); 34 + };
+4 -2
app/home/CreateNewButton.tsx
··· 39 39 }, 40 40 ), 41 41 ); 42 - export const CreateNewLeafletButton = (props: {}) => { 42 + export const CreateNewLeafletButton = (props: { 43 + noLabelOnMobile?: boolean; 44 + }) => { 43 45 let templates = useTemplateState((s) => s.templates); 44 46 return ( 45 47 <Menu 46 48 trigger={ 47 49 <HoverButton 48 50 id="new-leaflet-button" 49 - noLabelOnMobile 51 + noLabelOnMobile={props.noLabelOnMobile} 50 52 icon=<AddTiny className="m-1 shrink-0" /> 51 53 label="New Leaflet" 52 54 background="bg-accent-1"
+22 -34
app/home/HomeHelp.tsx
··· 1 1 "use client"; 2 - import { InfoSmall, PopoverArrow } from "components/Icons"; 2 + import { HelpSmall } from "components/Icons"; 3 3 import { HoverButton } from "components/Buttons"; 4 - import * as Popover from "@radix-ui/react-popover"; 4 + import { Popover } from "components/Popover"; 5 5 6 6 export const HomeHelp = () => { 7 7 return ( 8 - <Popover.Root> 9 - <Popover.Trigger> 8 + <Popover 9 + className="max-w-sm" 10 + trigger={ 10 11 <HoverButton 11 - icon={<InfoSmall />} 12 + icon={<HelpSmall />} 12 13 noLabelOnMobile 13 14 label="Info" 14 15 background="bg-accent-1" 15 16 text="text-accent-2" 16 17 /> 17 - </Popover.Trigger> 18 - <Popover.Portal> 19 - <Popover.Content 20 - className="z-20 bg-white border border-[#CCCCCC] text-[#595959] rounded-md text-sm max-w-sm p-2" 21 - align="center" 22 - sideOffset={4} 23 - collisionPadding={16} 24 - > 25 - <div className="flex flex-col gap-2"> 26 - <p> 27 - Leaflets are saved to home <strong>per-device / browser</strong>{" "} 28 - using cookies. 29 - </p> 30 - <p> 31 - <strong> 32 - If you clear your cookies, they&apos;ll disappear. 33 - </strong> 34 - </p> 35 - <p> 36 - Please <a href="mailto:contact@hyperlink.academy">contact us</a>{" "} 37 - for help recovering Leaflets! 38 - </p> 39 - </div> 40 - <Popover.Arrow asChild width={16} height={8} viewBox="0 0 16 8"> 41 - <PopoverArrow arrowFill="#FFFFFF" arrowStroke="#CCCCCC" /> 42 - </Popover.Arrow> 43 - </Popover.Content> 44 - </Popover.Portal> 45 - </Popover.Root> 18 + } 19 + > 20 + <div className="flex flex-col gap-2"> 21 + <p> 22 + Leaflets are saved to home <strong>per-device / browser</strong> using 23 + cookies. 24 + </p> 25 + <p> 26 + <strong>If you clear your cookies, they&apos;ll disappear.</strong> 27 + </p> 28 + <p> 29 + Please <a href="mailto:contact@hyperlink.academy">contact us</a> for 30 + help recovering Leaflets! 31 + </p> 32 + </div> 33 + </Popover> 46 34 ); 47 35 };
+51 -20
app/home/LeafletList.tsx
··· 3 3 import { useEffect, useState } from "react"; 4 4 import { getHomeDocs, HomeDoc } from "./storage"; 5 5 import useSWR from "swr"; 6 - import { ReplicacheProvider } from "src/replicache"; 6 + import { Fact, ReplicacheProvider } from "src/replicache"; 7 7 import { LeafletPreview } from "./LeafletPreview"; 8 + import { useIdentityData } from "components/IdentityProvider"; 9 + import { Attributes } from "src/replicache/attributes"; 10 + import { getLeafletData } from "actions/getLeafletData"; 11 + import { getIdentityData } from "actions/getIdentityData"; 8 12 9 - export function LeafletList() { 10 - let { data: leaflets } = useSWR("leaflets", () => getHomeDocs(), { 13 + export function LeafletList(props: { 14 + initialFacts: { 15 + [root_entity: string]: Fact<keyof typeof Attributes>[]; 16 + }; 17 + }) { 18 + let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { 11 19 fallbackData: [], 12 20 }); 21 + let { identity } = useIdentityData(); 22 + let { data: initialFacts, mutate } = useSWR( 23 + "home-leaflet-data", 24 + () => { 25 + if (identity) 26 + return getLeafletData( 27 + identity.permission_token_on_homepage.map( 28 + (ptrh) => ptrh.permission_tokens.root_entity, 29 + ), 30 + ); 31 + }, 32 + { fallbackData: props.initialFacts }, 33 + ); 34 + useEffect(() => { 35 + mutate(); 36 + }, [localLeaflets.length, mutate]); 37 + let leaflets = identity 38 + ? identity.permission_token_on_homepage 39 + .sort((a, b) => (a.created_at > b.created_at ? -1 : 1)) 40 + .map((ptoh) => ptoh.permission_tokens) 41 + : localLeaflets 42 + .sort((a, b) => (a.added_at > b.added_at ? -1 : 1)) 43 + .filter((d) => !d.hidden) 44 + .map((ll) => ll.token); 13 45 14 46 return ( 15 47 <div className="homeLeafletGrid grow w-full h-full overflow-y-scroll no-scrollbar "> 16 - <div className="grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-8 gap-x-4 sm:gap-6 grow pt-3 pb-28 sm:pt-6 sm:pb-12 sm:pl-6"> 17 - {leaflets 18 - .sort((a, b) => (a.added_at > b.added_at ? -1 : 1)) 19 - .filter((d) => !d.hidden) 20 - .map(({ token: leaflet }) => ( 21 - <ReplicacheProvider 22 - key={leaflet.id} 23 - rootEntity={leaflet.root_entity} 48 + <div className="grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-6 gap-x-4 sm:gap-6 grow pt-3 pb-28 sm:pt-6 sm:pb-12 sm:pl-6 sm:pr-1"> 49 + {leaflets.map((leaflet) => ( 50 + <ReplicacheProvider 51 + initialFactsOnly={!!identity} 52 + key={leaflet.id} 53 + rootEntity={leaflet.root_entity} 54 + token={leaflet} 55 + name={leaflet.root_entity} 56 + initialFacts={initialFacts?.[leaflet.root_entity] || []} 57 + > 58 + <LeafletPreview 24 59 token={leaflet} 25 - name={leaflet.root_entity} 26 - initialFacts={[]} 27 - > 28 - <LeafletPreview 29 - token={leaflet} 30 - leaflet_id={leaflet.root_entity} 31 - /> 32 - </ReplicacheProvider> 33 - ))} 60 + leaflet_id={leaflet.root_entity} 61 + loggedIn={!!identity} 62 + /> 63 + </ReplicacheProvider> 64 + ))} 34 65 </div> 35 66 </div> 36 67 );
+26 -4
app/home/LeafletOptions.tsx
··· 8 8 } from "components/Icons"; 9 9 import { Menu, MenuItem } from "components/Layout"; 10 10 import { PermissionToken } from "src/replicache"; 11 - import { mutate } from "swr"; 12 11 import { hideDoc } from "./storage"; 13 12 import { useState } from "react"; 14 13 import { ButtonPrimary } from "components/Buttons"; 15 14 import { useTemplateState } from "./CreateNewButton"; 16 15 import { Item } from "@radix-ui/react-dropdown-menu"; 17 16 import { useSmoker } from "components/Toast"; 17 + import { removeLeafletFromHome } from "actions/removeLeafletFromHome"; 18 + import { useIdentityData } from "components/IdentityProvider"; 18 19 19 20 export const LeafletOptions = (props: { 20 21 leaflet: PermissionToken; 21 22 isTemplate: boolean; 23 + loggedIn: boolean; 22 24 }) => { 25 + let { mutate } = useIdentityData(); 23 26 let [state, setState] = useState<"normal" | "template">("normal"); 24 27 let [open, setOpen] = useState(false); 25 28 let smoker = useSmoker(); ··· 71 74 </MenuItem> 72 75 )} 73 76 <MenuItem 74 - onSelect={() => { 75 - hideDoc(props.leaflet); 76 - mutate("leaflets"); 77 + onSelect={async () => { 78 + console.log(props.loggedIn); 79 + if (props.loggedIn) { 80 + mutate( 81 + (s) => { 82 + if (!s) return s; 83 + return { 84 + ...s, 85 + permission_token_on_homepage: 86 + s.permission_token_on_homepage.filter( 87 + (ptrh) => 88 + ptrh.permission_tokens.id !== props.leaflet.id, 89 + ), 90 + }; 91 + }, 92 + { revalidate: false }, 93 + ); 94 + await removeLeafletFromHome([props.leaflet.id]); 95 + mutate(); 96 + } else { 97 + hideDoc(props.leaflet); 98 + } 77 99 }} 78 100 > 79 101 <HideSmall />
+16
app/home/LeafletPreview.module.css
··· 1 + @media (min-width: 640px) { 2 + .scaleLeafletDocPreview { 3 + transform: scale(calc(192 / var(--page-width-unitless))); 4 + } 5 + .scaleLeafletCanvasPreview { 6 + transform: scale(calc(192 / 1272)); 7 + } 8 + } 9 + 10 + .scaleLeafletDocPreview { 11 + transform: scale(calc(160 / var(--page-width-unitless))); 12 + } 13 + 14 + .scaleLeafletCanvasPreview { 15 + transform: scale(calc(160 / 1272)); 16 + }
+19 -18
app/home/LeafletPreview.tsx
··· 16 16 import { deleteLeaflet } from "actions/deleteLeaflet"; 17 17 import { removeDocFromHome } from "./storage"; 18 18 import { mutate } from "swr"; 19 - import useMeasure from "react-use-measure"; 20 19 import { ButtonPrimary } from "components/Buttons"; 21 20 import { LeafletOptions } from "./LeafletOptions"; 22 21 import { CanvasContent } from "components/Canvas"; ··· 24 23 import { TemplateSmall } from "components/Icons"; 25 24 import { theme } from "tailwind.config"; 26 25 import { useTemplateState } from "./CreateNewButton"; 26 + import styles from "./LeafletPreview.module.css"; 27 27 28 28 export const LeafletPreview = (props: { 29 29 token: PermissionToken; 30 30 leaflet_id: string; 31 + loggedIn: boolean; 31 32 }) => { 32 33 let [state, setState] = useState<"normal" | "deleting">("normal"); 33 34 let isTemplate = useTemplateState( ··· 42 43 return ( 43 44 <div className="relative max-h-40 h-40"> 44 45 <ThemeProvider local entityID={root}> 45 - <div className="rounded-lg hover:shadow-sm overflow-clip border border-border outline outline-transparent hover:outline-border bg-bg-leaflet grow w-full h-full"> 46 + <div className="rounded-lg hover:shadow-sm overflow-clip border border-border outline outline-2 outline-transparent outline-offset-1 hover:outline-border bg-bg-leaflet grow w-full h-full"> 46 47 {state === "normal" ? ( 47 - <Link 48 - href={"/" + props.token.id} 49 - className={`no-underline hover:no-underline text-primary h-full`} 50 - > 48 + <div className="relative w-full h-full"> 49 + <Link 50 + href={"/" + props.token.id} 51 + className={`no-underline hover:no-underline text-primary absolute inset-0 z-10 w-full h-full`} 52 + ></Link> 51 53 <ThemeBackgroundProvider entityID={root}> 52 54 <div className="leafletPreview grow shrink-0 h-full w-full px-2 pt-2 sm:px-3 sm:pt-3 flex items-end pointer-events-none"> 53 55 <div 54 - className="leafletContentWrapper w-full h-full max-w-48 mx-auto border border-border-light border-b-0 rounded-t-md overflow-clip" 56 + className="leafletContentWrapper h-full sm:w-48 w-40 mx-auto border border-border-light border-b-0 rounded-t-md overflow-clip" 55 57 style={{ 56 58 backgroundColor: 57 59 "rgba(var(--bg-page), var(--bg-page-alpha))", ··· 61 63 </div> 62 64 </div> 63 65 </ThemeBackgroundProvider> 64 - </Link> 66 + </div> 65 67 ) : ( 66 68 <LeafletAreYouSure token={props.token} setState={setState} /> 67 69 )} 68 70 </div> 69 71 <div className="flex justify-end pt-1 shrink-0"> 70 - <LeafletOptions leaflet={props.token} isTemplate={isTemplate} /> 72 + <LeafletOptions 73 + leaflet={props.token} 74 + isTemplate={isTemplate} 75 + loggedIn={props.loggedIn} 76 + /> 71 77 </div> 72 78 <LeafletTemplateIndicator isTemplate={isTemplate} /> 73 79 </ThemeProvider> ··· 79 85 let type = useEntity(props.entityID, "page/type")?.data.value || "doc"; 80 86 let blocks = useBlocks(props.entityID); 81 87 let previewRef = useRef<HTMLDivElement | null>(null); 82 - let [ref, dimensions] = useMeasure(); 83 88 84 89 if (type === "canvas") 85 90 return ( 86 91 <div 87 - ref={ref} 88 - className={`pageLinkBlockPreview shrink-0 h-full w-full overflow-clip relative bg-bg-page shadow-sm rounded-md`} 92 + className={`pageLinkBlockPreview shrink-0 h-full overflow-clip relative bg-bg-page shadow-sm rounded-md`} 89 93 > 90 94 <div 91 - className={`absolute top-0 left-0 origin-top-left pointer-events-none `} 95 + className={`absolute top-0 left-0 origin-top-left pointer-events-none ${styles.scaleLeafletCanvasPreview}`} 92 96 style={{ 93 97 width: `1272px`, 94 98 height: "calc(1272px * 2)", 95 - transform: `scale(calc((${dimensions.width} / 1272 )))`, 96 99 }} 97 100 > 98 101 <CanvasContent entityID={props.entityID} preview /> ··· 103 106 return ( 104 107 <div 105 108 ref={previewRef} 106 - className={`pageLinkBlockPreview w-full h-full overflow-clip flex flex-col gap-0.5 no-underline relative`} 109 + className={`pageLinkBlockPreview h-full overflow-clip flex flex-col gap-0.5 no-underline relative`} 107 110 > 108 - <div className="w-full" ref={ref} /> 109 111 <div 110 - className="absolute top-0 left-0 w-full h-full origin-top-left pointer-events-none" 112 + className={`absolute top-0 left-0 w-full h-full origin-top-left pointer-events-none ${styles.scaleLeafletDocPreview}`} 111 113 style={{ 112 114 width: `var(--page-width-units)`, 113 - transform: `scale(calc(${dimensions.width} / var(--page-width-unitless)))`, 114 115 }} 115 116 > 116 117 {blocks.slice(0, 10).map((b, index, arr) => {
+25
app/home/LoggedOutWarning.tsx
··· 1 + "use client"; 2 + import { useIdentityData } from "components/IdentityProvider"; 3 + import { LoginButton } from "components/LoginButton"; 4 + 5 + export const LoggedOutWarning = (props: {}) => { 6 + let { identity } = useIdentityData(); 7 + if (identity) return null; 8 + return ( 9 + <div 10 + className={` 11 + homeWarning z-10 shrink-0 12 + bg-bg-page rounded-md 13 + absolute bottom-14 left-2 right-2 14 + sm:static sm:mr-1 sm:ml-6 sm:mt-6 border border-border-light`} 15 + > 16 + <div className="px-2 py-1 text-sm text-tertiary flex sm:flex-row flex-col sm:gap-4 gap-1 items-center sm:justify-between"> 17 + <p className="font-bold"> 18 + Log in to collect all your Leaflets and access them on multiple 19 + devices 20 + </p> 21 + <LoginButton /> 22 + </div> 23 + </div> 24 + ); 25 + };
+76 -23
app/home/page.tsx
··· 1 - import { AddTiny } from "components/Icons"; 2 1 import { cookies } from "next/headers"; 3 2 import { Fact, ReplicacheProvider } from "src/replicache"; 4 3 import { createServerClient } from "@supabase/ssr"; ··· 17 16 import { HomeHelp } from "./HomeHelp"; 18 17 import { LeafletList } from "./LeafletList"; 19 18 import { CreateNewLeafletButton } from "./CreateNewButton"; 19 + import { getIdentityData } from "actions/getIdentityData"; 20 + import { LoginButton } from "components/LoginButton"; 21 + import { HelpPopover } from "components/HelpPopover"; 22 + import { AccountSettings } from "./AccountSettings"; 23 + import { LoggedOutWarning } from "./LoggedOutWarning"; 20 24 21 25 let supabase = createServerClient<Database>( 22 26 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 25 29 ); 26 30 export default async function Home() { 27 31 let cookieStore = cookies(); 28 - let identity = cookieStore.get("identity")?.value; 32 + 33 + let auth_token = cookieStore.get("auth_token")?.value; 34 + let auth_res = auth_token ? await getIdentityData() : null; 35 + let identity: string | undefined; 36 + if (auth_res) identity = auth_res.id; 37 + else identity = cookieStore.get("identity")?.value; 29 38 let needstosetcookie = false; 30 39 if (!identity) { 31 40 const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); ··· 42 51 cookies().set("identity", identity as string, { sameSite: "strict" }); 43 52 } 44 53 45 - let res = await supabase 46 - .from("identities") 47 - .select( 48 - `*, 49 - permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)) 50 - `, 51 - ) 52 - .eq("id", identity) 53 - .single(); 54 - if (!res.data) return <div>{JSON.stringify(res.error)}</div>; 55 - if (!res.data.permission_tokens) return <div>no home page wierdly</div>; 54 + let permission_token = auth_res?.home_leaflet; 55 + if (!permission_token) { 56 + let res = await supabase 57 + .from("identities") 58 + .select( 59 + `*, 60 + permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)) 61 + `, 62 + ) 63 + .eq("id", identity) 64 + .single(); 65 + permission_token = res.data?.permission_tokens; 66 + } 67 + 68 + if (!permission_token) return <div>no home page wierdly</div>; 56 69 let { data } = await supabase.rpc("get_facts", { 57 - root: res.data.permission_tokens?.root_entity, 70 + root: permission_token.root_entity, 58 71 }); 59 72 let initialFacts = (data as unknown as Fact<keyof typeof Attributes>[]) || []; 60 - let root_entity = res.data.permission_tokens.root_entity; 73 + 74 + let root_entity = permission_token.root_entity; 75 + let home_docs_initialFacts: { 76 + [root_entity: string]: Fact<keyof typeof Attributes>[]; 77 + } = {}; 78 + if (auth_res) { 79 + let all_facts = await supabase.rpc("get_facts_for_roots", { 80 + max_depth: 3, 81 + roots: auth_res.permission_token_on_homepage.map( 82 + (r) => r.permission_tokens.root_entity, 83 + ), 84 + }); 85 + if (all_facts.data) 86 + home_docs_initialFacts = all_facts.data.reduce( 87 + (acc, fact) => { 88 + if (!acc[fact.root_id]) acc[fact.root_id] = []; 89 + acc[fact.root_id].push( 90 + fact as unknown as Fact<keyof typeof Attributes>, 91 + ); 92 + return acc; 93 + }, 94 + {} as { [key: string]: Fact<keyof typeof Attributes>[] }, 95 + ); 96 + } 61 97 return ( 62 98 <ReplicacheProvider 63 99 rootEntity={root_entity} 64 - token={res.data.permission_tokens} 100 + token={permission_token} 65 101 name={root_entity} 66 102 initialFacts={initialFacts} 67 103 > 68 104 <IdentitySetter cb={setCookie} call={needstosetcookie} /> 69 105 <EntitySetProvider 70 - set={res.data.permission_tokens.permission_token_rights[0].entity_set} 106 + set={permission_token.permission_token_rights[0].entity_set} 71 107 > 72 108 <ThemeProvider entityID={root_entity}> 73 109 <div className="flex h-full bg-bg-leaflet"> 74 110 <ThemeBackgroundProvider entityID={root_entity}> 75 - <div className="home relative max-w-screen-lg w-full h-full mx-auto flex sm:flex-row flex-col-reverse px-2 sm:px-6 "> 76 - <div className="homeOptions z-10 shrink-0 sm:static absolute bottom-0 place-self-end sm:place-self-start flex sm:flex-col flex-row-reverse gap-2 sm:w-fit w-full items-center pb-2 pt-1 sm:pt-7"> 77 - <CreateNewLeafletButton /> 78 - <HomeHelp /> 79 - <ThemePopover entityID={root_entity} home /> 111 + <div className="home relative max-w-screen-lg w-full h-screen mx-auto flex sm:flex-row sm:items-stretch flex-col-reverse px-2 sm:px-6 "> 112 + {!auth_res && ( 113 + <div className="sm:hidden block"> 114 + <LoggedOutWarning /> 115 + </div> 116 + )} 117 + <div className="homeOptions z-10 shrink-0 sm:static absolute bottom-0 left-2 right-2 place-self-end sm:place-self-start flex sm:flex-col flex-row-reverse sm:w-fit w-full items-center px-2 sm:px-0 pb-2 pt-2 sm:pt-7 sm:bg-transparent bg-bg-page border-border border-t sm:border-none"> 118 + <div className="flex sm:flex-col flex-row-reverse gap-2 shrink-0 place-self-end"> 119 + <CreateNewLeafletButton /> 120 + <ThemePopover entityID={root_entity} home /> 121 + <HelpPopover noShortcuts /> 122 + {auth_res && <AccountSettings />} 123 + </div> 80 124 </div> 81 - <LeafletList /> 125 + <div 126 + className={`h-full w-full flex flex-col ${!auth_res && "sm:pb-0 pb-16"}`} 127 + > 128 + {!auth_res && ( 129 + <div className="sm:block hidden"> 130 + <LoggedOutWarning /> 131 + </div> 132 + )} 133 + <LeafletList initialFacts={home_docs_initialFacts} /> 134 + </div> 82 135 </div> 83 136 </ThemeBackgroundProvider> 84 137 </div>
+4 -1
app/layout.tsx
··· 5 5 import "./globals.css"; 6 6 import localFont from "next/font/local"; 7 7 import { PopUpProvider } from "components/Toast"; 8 + import { IdentityProviderServer } from "components/IdentityProviderServer"; 8 9 9 10 export const metadata = { 10 11 title: "Leaflet", ··· 63 64 <ServiceWorker /> 64 65 <InitialPageLoad> 65 66 <PopUpProvider> 66 - <ViewportSizeLayout>{children}</ViewportSizeLayout> 67 + <IdentityProviderServer> 68 + <ViewportSizeLayout>{children}</ViewportSizeLayout> 69 + </IdentityProviderServer> 67 70 </PopUpProvider> 68 71 </InitialPageLoad> 69 72 </body>
+144
app/login/LoginForm.tsx
··· 1 + "use client"; 2 + import { 3 + confirmEmailAuthToken, 4 + requestAuthEmailToken, 5 + } from "actions/emailAuth"; 6 + import { loginWithEmailToken } from "actions/login"; 7 + import { getHomeDocs } from "app/home/storage"; 8 + import { ButtonPrimary } from "components/Buttons"; 9 + import { InputWithLabel } from "components/Layout"; 10 + import { useSmoker, useToaster } from "components/Toast"; 11 + import React, { useState } from "react"; 12 + import useSWR, { mutate } from "swr"; 13 + 14 + export default function LoginForm() { 15 + type FormState = 16 + | { 17 + stage: "email"; 18 + email: string; 19 + } 20 + | { 21 + stage: "code"; 22 + email: string; 23 + tokenId: string; 24 + confirmationCode: string; 25 + }; 26 + 27 + const [formState, setFormState] = useState<FormState>({ 28 + stage: "email", 29 + email: "", 30 + }); 31 + 32 + let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { 33 + fallbackData: [], 34 + }); 35 + 36 + const handleSubmitEmail = async (e: React.FormEvent) => { 37 + e.preventDefault(); 38 + const tokenId = await requestAuthEmailToken(formState.email); 39 + setFormState({ 40 + stage: "code", 41 + email: formState.email, 42 + tokenId, 43 + confirmationCode: "", 44 + }); 45 + }; 46 + 47 + let smoker = useSmoker(); 48 + let toaster = useToaster(); 49 + 50 + const handleSubmitCode = async (e: React.FormEvent) => { 51 + e.preventDefault(); 52 + let rect = e.currentTarget.getBoundingClientRect(); 53 + 54 + if (formState.stage !== "code") return; 55 + const confirmedToken = await confirmEmailAuthToken( 56 + formState.tokenId, 57 + formState.confirmationCode, 58 + ); 59 + 60 + if (!confirmedToken) { 61 + smoker({ 62 + error: true, 63 + text: "incorrect code!", 64 + position: { 65 + y: rect.bottom - 16, 66 + x: rect.right - 220, 67 + }, 68 + }); 69 + } else { 70 + await loginWithEmailToken(localLeaflets.filter((l) => !l.hidden)); 71 + mutate("identity"); 72 + toaster({ 73 + content: <div className="font-bold">Logged in! Welcome!</div>, 74 + type: "success", 75 + }); 76 + } 77 + }; 78 + 79 + if (formState.stage === "code") { 80 + return ( 81 + <div className="w-full max-w-md flex flex-col gap-3 py-1"> 82 + <div className=" text-secondary font-bold"> 83 + Please enter the code we sent to 84 + <div className="italic truncate">{formState.email}</div> 85 + </div> 86 + <form onSubmit={handleSubmitCode} className="flex flex-col gap-2 "> 87 + <InputWithLabel 88 + label="code" 89 + type="text" 90 + placeholder="000000" 91 + value={formState.confirmationCode} 92 + onChange={(e) => 93 + setFormState({ 94 + ...formState, 95 + confirmationCode: e.target.value, 96 + }) 97 + } 98 + required 99 + /> 100 + 101 + <ButtonPrimary 102 + type="submit" 103 + className="place-self-end" 104 + disabled={formState.confirmationCode === ""} 105 + onMouseDown={(e) => {}} 106 + > 107 + Confirm 108 + </ButtonPrimary> 109 + </form> 110 + </div> 111 + ); 112 + } 113 + 114 + return ( 115 + <div className="flex flex-col gap-3 w-full max-w-sm pb-1"> 116 + <div className="flex flex-col gap-0.5"> 117 + <h3>Log In or Sign Up</h3> 118 + <div className=" text-secondary"> 119 + Save your leaflets and access them on multiple devices! 120 + </div> 121 + </div> 122 + <form onSubmit={handleSubmitEmail} className="flex flex-col gap-2"> 123 + <InputWithLabel 124 + label="Email" 125 + type="email" 126 + placeholder="email@example.com" 127 + value={formState.email} 128 + className="" 129 + onChange={(e) => 130 + setFormState({ 131 + ...formState, 132 + email: e.target.value, 133 + }) 134 + } 135 + required 136 + /> 137 + 138 + <ButtonPrimary type="submit" className="place-self-end"> 139 + Log In / Sign Up 140 + </ButtonPrimary> 141 + </form> 142 + </div> 143 + ); 144 + }
+23
app/login/page.tsx
··· 1 + import { cookies } from "next/headers"; 2 + import LoginForm from "./LoginForm"; 3 + import { logout } from "actions/logout"; 4 + 5 + export default function LoginPage() { 6 + let cookieStore = cookies(); 7 + let identity = cookieStore.get("auth_token")?.value; 8 + if (!identity) 9 + return ( 10 + <div> 11 + this is a login page! 12 + <LoginForm /> 13 + </div> 14 + ); 15 + return ( 16 + <div> 17 + identity: {identity} 18 + <form action={logout}> 19 + <button>logout</button> 20 + </form> 21 + </div> 22 + ); 23 + }
+21 -14
components/Buttons.tsx
··· 1 - import React from "react"; 1 + import React, { forwardRef } from "react"; 2 2 import * as RadixTooltip from "@radix-ui/react-tooltip"; 3 3 import { theme } from "tailwind.config"; 4 4 import { PopoverArrow } from "./Icons"; ··· 8 8 } from "./ThemeManager/ThemeProvider"; 9 9 10 10 type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">; 11 - export function ButtonPrimary( 12 - props: { 11 + export const ButtonPrimary = forwardRef< 12 + HTMLButtonElement, 13 + ButtonProps & { 13 14 fullWidth?: boolean; 15 + fullWidthOnMobile?: boolean; 14 16 children: React.ReactNode; 15 17 compact?: boolean; 16 - } & ButtonProps, 17 - ) { 18 + } 19 + >((props, ref) => { 18 20 return ( 19 21 <button 20 22 {...props} 21 - className={`m-0 h-max ${props.fullWidth ? "w-full" : "w-max"} ${props.compact ? "py-0 px-1" : "px-2 py-0.5 "} 22 - bg-accent-1 outline-transparent 23 - rounded-md text-base font-bold text-accent-2 24 - flex gap-2 items-center justify-center shrink-0 25 - transparent-outline hover:outline-accent-1 outline-offset-1 26 - disabled:bg-border-light disabled:text-border disabled:hover:text-border 27 - ${props.className} 28 - `} 23 + ref={ref} 24 + className={` 25 + m-0 h-max 26 + ${props.fullWidth ? "w-full" : props.fullWidthOnMobile ? "w-full sm:w-max" : "w-max"} 27 + ${props.compact ? "py-0 px-1" : "px-2 py-0.5 "} 28 + bg-accent-1 outline-transparent border border-accent-1 29 + rounded-md text-base font-bold text-accent-2 30 + flex gap-2 items-center justify-center shrink-0 31 + transparent-outline hover:outline-accent-1 outline-offset-1 32 + disabled:bg-border-light disabled:border-border-light disabled:text-border disabled:hover:text-border 33 + ${props.className} 34 + `} 29 35 > 30 36 {props.children} 31 37 </button> 32 38 ); 33 - } 39 + }); 40 + ButtonPrimary.displayName = "ButtonPrimary"; 34 41 35 42 export const HoverButton = (props: { 36 43 id?: string;
+59 -54
components/HelpPopover.tsx
··· 1 + "use client"; 1 2 import { isMac } from "@react-aria/utils"; 2 3 import { HelpSmall } from "./Icons"; 3 4 import { ShortcutKey } from "./Layout"; ··· 9 10 import { useState } from "react"; 10 11 import { HoverButton } from "./Buttons"; 11 12 12 - export const HelpPopover = () => { 13 + export const HelpPopover = (props: { noShortcuts?: boolean }) => { 13 14 let entity_set = useEntitySetContext(); 14 15 return entity_set.permissions.write ? ( 15 16 <Popover ··· 47 48 url="https://leaflet.pub/legal" 48 49 /> 49 50 <Media mobile={false}> 50 - <hr className="text-border my-1" /> 51 - <div className="flex flex-col gap-1"> 52 - <Label>Text Shortcuts</Label> 53 - <KeyboardShortcut name="Bold" keys={[metaKey(), "B"]} /> 54 - <KeyboardShortcut name="Italic" keys={[metaKey(), "I"]} /> 55 - <KeyboardShortcut name="Underline" keys={[metaKey(), "U"]} /> 56 - <KeyboardShortcut 57 - name="Highlight" 58 - keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "H"]} 59 - /> 60 - <KeyboardShortcut 61 - name="Strikethrough" 62 - keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]} 63 - /> 64 - <KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} /> 51 + {!props.noShortcuts && ( 52 + <> 53 + <hr className="text-border my-1" /> 54 + <div className="flex flex-col gap-1"> 55 + <Label>Text Shortcuts</Label> 56 + <KeyboardShortcut name="Bold" keys={[metaKey(), "B"]} /> 57 + <KeyboardShortcut name="Italic" keys={[metaKey(), "I"]} /> 58 + <KeyboardShortcut name="Underline" keys={[metaKey(), "U"]} /> 59 + <KeyboardShortcut 60 + name="Highlight" 61 + keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "H"]} 62 + /> 63 + <KeyboardShortcut 64 + name="Strikethrough" 65 + keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]} 66 + /> 67 + <KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} /> 65 68 66 - <Label>Block Shortcuts</Label> 67 - {/* shift + up/down arrows (or click + drag): select multiple blocks */} 68 - <KeyboardShortcut 69 - name="Move Block Up" 70 - keys={["Shift", metaKey(), "↑"]} 71 - /> 72 - <KeyboardShortcut 73 - name="Move Block Down" 74 - keys={["Shift", metaKey(), "↓"]} 75 - /> 76 - {/* cmd/ctrl-a: first selects all text in a block; again selects all blocks on page */} 77 - {/* cmd/ctrl + up/down arrows: go to beginning / end of doc */} 69 + <Label>Block Shortcuts</Label> 70 + {/* shift + up/down arrows (or click + drag): select multiple blocks */} 71 + <KeyboardShortcut 72 + name="Move Block Up" 73 + keys={["Shift", metaKey(), "↑"]} 74 + /> 75 + <KeyboardShortcut 76 + name="Move Block Down" 77 + keys={["Shift", metaKey(), "↓"]} 78 + /> 79 + {/* cmd/ctrl-a: first selects all text in a block; again selects all blocks on page */} 80 + {/* cmd/ctrl + up/down arrows: go to beginning / end of doc */} 78 81 79 - <Label>Canvas Shortcuts</Label> 80 - <OtherShortcut name="Add Block" description="Double click" /> 81 - <OtherShortcut name="Select Block" description="Long press" /> 82 + <Label>Canvas Shortcuts</Label> 83 + <OtherShortcut name="Add Block" description="Double click" /> 84 + <OtherShortcut name="Select Block" description="Long press" /> 82 85 83 - <Label>Outliner Shortcuts</Label> 84 - <KeyboardShortcut 85 - name="Make List" 86 - keys={[metaKey(), isMac() ? "Opt" : "Alt", "L"]} 87 - /> 88 - {/* tab / shift + tab: indent / outdent */} 89 - <KeyboardShortcut 90 - name="Toggle Checkbox" 91 - keys={[metaKey(), "Enter"]} 92 - /> 93 - <KeyboardShortcut 94 - name="Toggle Fold" 95 - keys={[metaKey(), "Shift", "Enter"]} 96 - /> 97 - <KeyboardShortcut 98 - name="Fold All" 99 - keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "↑"]} 100 - /> 101 - <KeyboardShortcut 102 - name="Unfold All" 103 - keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "↓"]} 104 - /> 105 - </div> 86 + <Label>Outliner Shortcuts</Label> 87 + <KeyboardShortcut 88 + name="Make List" 89 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "L"]} 90 + /> 91 + {/* tab / shift + tab: indent / outdent */} 92 + <KeyboardShortcut 93 + name="Toggle Checkbox" 94 + keys={[metaKey(), "Enter"]} 95 + /> 96 + <KeyboardShortcut 97 + name="Toggle Fold" 98 + keys={[metaKey(), "Shift", "Enter"]} 99 + /> 100 + <KeyboardShortcut 101 + name="Fold All" 102 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "↑"]} 103 + /> 104 + <KeyboardShortcut 105 + name="Unfold All" 106 + keys={[metaKey(), isMac() ? "Opt" : "Alt", "Shift", "↓"]} 107 + /> 108 + </div> 109 + </> 110 + )} 106 111 </Media> 107 112 </div> 108 113 </Popover>
+25 -22
components/HomeButton.tsx
··· 11 11 let params = useParams(); 12 12 let isSubpage = !!searchParams.get("page"); 13 13 14 - if (isSubpage) 14 + if (permissions.write) 15 15 return ( 16 - <Link href={`/${params.leaflet_id}`}> 17 - <HoverButton 18 - noLabelOnMobile 19 - icon={<BackToLeafletSmall />} 20 - label="See Full Leaflet" 21 - background="bg-accent-1" 22 - text="text-accent-2" 23 - /> 24 - </Link> 16 + <> 17 + {isSubpage && ( 18 + <Link href={`/${params.leaflet_id}`}> 19 + <HoverButton 20 + noLabelOnMobile 21 + icon={<BackToLeafletSmall />} 22 + label="See Full Leaflet" 23 + background="bg-accent-1" 24 + text="text-accent-2" 25 + /> 26 + </Link> 27 + )} 28 + 29 + <Link href="/home"> 30 + <HoverButton 31 + noLabelOnMobile 32 + icon={<HomeSmall />} 33 + label="Go Home" 34 + background="bg-accent-1" 35 + text="text-accent-2" 36 + /> 37 + </Link> 38 + </> 25 39 ); 26 - if (!permissions.write) return null; 27 - return ( 28 - <Link href="/home"> 29 - <HoverButton 30 - noLabelOnMobile 31 - icon={<HomeSmall />} 32 - label="Go Home" 33 - background="bg-accent-1" 34 - text="text-accent-2" 35 - /> 36 - </Link> 37 - ); 40 + return null; 38 41 }
+39
components/Icons.tsx
··· 445 445 ); 446 446 }; 447 447 448 + export const LogoutSmall = (props: Props) => { 449 + return ( 450 + <svg 451 + width="24" 452 + height="24" 453 + viewBox="0 0 24 24" 454 + fill="none" 455 + xmlns="http://www.w3.org/2000/svg" 456 + {...props} 457 + > 458 + <path 459 + fillRule="evenodd" 460 + clipRule="evenodd" 461 + d="M2.72545 7.55075L6.93535 10.1083L2.68652 11.0528V8.27643C2.68652 8.04069 2.69936 7.7979 2.72545 7.55075ZM1.68652 8.27643C1.68652 7.73323 1.74558 7.16457 1.86532 6.59318C2.38456 4.11541 4.08055 1.47172 7.22846 0.731262C8.8403 0.35212 10.2985 0.892465 11.3372 1.89122C12.3176 2.83384 12.9367 4.19263 12.9949 5.61158C14.2911 5.61366 15.3412 6.66508 15.3412 7.96179C15.3412 8.27057 15.2817 8.56545 15.1735 8.83556C15.2461 8.82928 15.3186 8.82403 15.391 8.81933C16.1631 8.76924 16.9452 8.89575 17.7198 9.02103C18.045 9.07363 18.3688 9.12601 18.6901 9.16502C18.9798 9.2002 19.2831 9.29186 19.5649 9.50168C20.1284 9.9211 20.4485 10.5683 20.7604 11.1991C20.873 11.4267 20.9845 11.6521 21.1059 11.8639C21.2666 12.1789 21.307 12.5432 21.2338 12.8884C21.1631 13.2217 20.9468 13.6728 20.4377 13.9073C19.9681 14.1235 19.5172 14.0609 19.1948 13.925C19.0336 13.8571 18.891 13.7664 18.7717 13.6669C18.9401 14.1505 18.9976 14.6322 18.9949 15.1455C18.992 15.6864 18.9472 16.3025 18.9008 16.8131C19.109 16.8217 19.3181 16.8339 19.5269 16.846C19.91 16.8684 20.2943 16.8908 20.6767 16.8908C21.0472 16.8908 21.4492 16.9855 21.789 17.2177C22.1444 17.4605 22.4583 17.8844 22.4583 18.4532C22.4583 18.9208 22.2814 19.3734 21.8831 19.6818C21.5406 19.9469 21.1499 20.0123 20.8959 20.0317C19.3828 20.1473 18.4316 20.1859 17.4952 20.1859C17.2346 20.1718 16.9741 20.1283 16.7242 20.0521C16.4925 19.9814 16.0246 19.8081 15.7353 19.3601C15.7108 19.3221 15.4425 18.7583 15.4425 18.7583C15.4154 19.2517 15.3843 19.7606 15.3504 20.2049C15.3118 20.7108 15.2633 21.2336 15.1943 21.5194L15.1886 21.5431C15.1551 21.6848 15.0743 22.0268 14.8524 22.3127C14.54 22.7153 14.0913 22.8679 13.6482 22.8679C13.346 22.8679 13.0201 22.7988 12.7307 22.5971C12.4717 22.4165 12.3169 22.182 12.2261 21.9672L3.93764 24L1.68652 22.7546V8.27643ZM8.50928 14.029C8.3246 13.8372 8.20741 13.6154 8.14644 13.3842L2.68652 14.6718V15.9403L9.17477 14.4449C8.94495 14.3647 8.70862 14.2361 8.50928 14.029ZM13.3665 14.3553C13.2473 14.455 13.1209 14.5301 12.9994 14.5871V15.5995C13.1494 15.3197 13.3064 15.05 13.4463 14.769C13.4301 14.6624 13.4021 14.5178 13.3665 14.3553ZM11.0941 17.5438L12.0416 18.0905C12.0247 18.2628 12.0207 18.4396 12.0226 18.6165L4.19688 20.4841L2.68652 19.6184V19.5078L11.0941 17.5438ZM4.60253 21.4154L12.053 19.6373C12.0561 19.76 12.0601 19.8827 12.0641 20.0054C12.0746 20.3283 12.0851 20.6512 12.0791 20.9736L4.5384 22.823L4.60253 21.4154ZM2.68652 12.0772V13.6444L7.75764 12.4485V10.9499L2.68652 12.0772ZM2.68652 16.9665L10.6728 15.1258V16.6153L2.68652 18.4809V16.9665ZM2.68652 20.771L3.60681 21.2985L3.54569 22.6403L2.68652 22.165V20.771ZM12.9911 6.61157C12.2454 6.61157 11.6409 7.21609 11.6409 7.96179C11.6409 8.7075 12.2454 9.31201 12.9911 9.31201C13.7369 9.31201 14.3414 8.7075 14.3414 7.96179C14.3414 7.21609 13.7369 6.61157 12.9911 6.61157ZM11.8873 13.8072C12.128 13.8072 12.562 13.7671 12.7691 13.5466C12.9762 13.3262 13.8696 12.3264 13.8696 12.3264C13.8696 12.3264 14.4616 14.3451 14.4616 14.894C14.4616 15.0075 14.2964 15.3096 14.0764 15.7119C13.7416 16.3242 13.2798 17.1686 13.0796 17.9346C12.928 18.5144 12.9802 19.2082 13.0292 19.86C13.0598 20.267 13.0815 20.641 13.0793 21.0127C13.0771 21.3819 13.0743 21.8679 13.6483 21.8679C14.0669 21.8679 14.143 21.6135 14.2223 21.2847C14.3144 20.9032 14.4001 19.553 14.4616 18.3767C14.6345 18.1582 14.8802 17.8768 15.1306 17.5985C15.4533 17.2399 15.8785 16.774 16.0872 16.6014C16.3085 16.4183 16.5746 16.2399 16.867 16.207C17.1414 16.1762 17.3889 16.3736 17.4198 16.648C17.4502 16.9181 17.2593 17.1621 16.9916 17.1992C16.6833 17.3103 16.4163 17.651 16.2457 17.9121C16.3597 18.3229 16.4724 18.658 16.5754 18.8175C16.7531 19.0926 17.2659 19.1611 17.4954 19.1859C18.3988 19.1859 19.3233 19.149 20.8198 19.0346C21.1715 19.0077 21.4585 18.8726 21.4585 18.4532C21.4585 18.11 21.0963 17.8908 20.6769 17.8908C20.122 17.8908 19.5662 17.8605 19.0105 17.8303C18.6045 17.8082 17.8495 17.8303 17.7929 17.7758C17.761 17.7451 17.7825 17.6116 17.8167 17.3993C17.9714 16.4396 18.2046 15.2236 17.9229 14.2884C17.5623 13.0915 16.659 11.4324 16.659 11.4324L18.2969 11.3624C18.5943 11.8351 19.2162 12.6632 19.3205 12.8021C19.4791 13.0131 19.774 13.112 20.0195 12.999C20.2935 12.8728 20.3062 12.4967 20.2264 12.3401C19.8747 11.7269 19.4216 10.9388 19.2672 10.6712C19.0743 10.3367 18.8946 10.1972 18.5697 10.1577C18.1413 10.1057 17.7387 10.0493 17.3686 9.99737C16.5469 9.88216 15.8851 9.78936 15.456 9.81723C14.8336 9.85753 14.3202 9.93273 13.6384 10.3998C13.1059 10.7646 12.6639 11.3226 12.2396 11.8583C12.0743 12.067 11.9117 12.2723 11.7474 12.4615C11.7474 12.4615 10.0906 12.3986 9.67705 12.3986C9.36806 12.3986 9.11608 12.6278 9.09643 12.9361C9.07551 13.2645 9.27953 13.4692 9.67719 13.5466C10.0748 13.6241 11.6895 13.8072 11.8873 13.8072Z" 462 + fill="currentColor" 463 + /> 464 + </svg> 465 + ); 466 + }; 467 + 448 468 export const PaintSmall = (props: Props) => { 449 469 return ( 450 470 <svg ··· 465 485 ); 466 486 }; 467 487 488 + export const AccountSmall = (props: Props) => { 489 + return ( 490 + <svg 491 + width="24" 492 + height="24" 493 + viewBox="0 0 24 24" 494 + fill="none" 495 + xmlns="http://www.w3.org/2000/svg" 496 + {...props} 497 + > 498 + <path 499 + fillRule="evenodd" 500 + clipRule="evenodd" 501 + d="M12.1186 4.00393C12.4755 4.21417 12.5944 4.67393 12.3841 5.03082L7.48811 13.3416C7.55452 13.3759 7.62551 13.4115 7.70068 13.4478C8.22649 13.7018 8.93184 13.9775 9.68768 14.0983C11.3104 14.3577 12.2206 14.15 12.5778 13.9741C12.9494 13.7911 13.399 13.944 13.582 14.3156C13.765 14.6872 13.6121 15.1368 13.2405 15.3198C12.4986 15.6851 11.2379 15.8651 9.45092 15.5795C8.5019 15.4278 7.65179 15.09 7.04836 14.7986C6.74415 14.6517 6.49643 14.5135 6.32286 14.4108C6.23595 14.3594 6.16726 14.3167 6.11899 14.2859C6.09485 14.2705 6.07578 14.2581 6.06205 14.2491L6.04551 14.2381L6.0403 14.2346L6.03847 14.2334L6.03744 14.2327L6.45839 13.6119C6.03716 14.2325 6.0373 14.2326 6.03744 14.2327C5.71013 14.0105 5.61139 13.5721 5.81219 13.2312L11.0917 4.26944C11.302 3.91255 11.7617 3.79368 12.1186 4.00393ZM5.99939 7.33218C6.76172 7.39081 7.43385 6.73443 7.50064 5.8661C7.56742 4.99778 7.00357 4.24634 6.24124 4.18771C5.4789 4.12907 4.80677 4.78546 4.73999 5.65378C4.67321 6.5221 5.23706 7.27355 5.99939 7.33218ZM17.0236 8.10185C17.7864 8.04973 18.3566 7.30312 18.2972 6.43426C18.2379 5.5654 17.5714 4.90331 16.8086 4.95543C16.0458 5.00755 15.4755 5.75416 15.5349 6.62302C15.5942 7.49188 16.2608 8.15397 17.0236 8.10185ZM16.8245 18.4262C17.1433 18.1618 17.1873 17.6889 16.9229 17.3701C16.6584 17.0513 16.1856 17.0073 15.8668 17.2717C13.8455 18.9485 11.3149 19.488 9.03465 19.0788C8.62695 19.0057 8.23713 19.2769 8.16398 19.6846C8.09083 20.0923 8.36204 20.4821 8.76974 20.5553C11.4587 21.0377 14.4429 20.4019 16.8245 18.4262Z" 502 + fill="currentColor" 503 + /> 504 + </svg> 505 + ); 506 + }; 468 507 export const ShareSmall = (props: Props) => { 469 508 return ( 470 509 <svg
+24
components/IdentityProvider.tsx
··· 1 + "use client"; 2 + import { getIdentityData } from "actions/getIdentityData"; 3 + import { createContext, useContext } from "react"; 4 + import useSWR, { KeyedMutator, mutate } from "swr"; 5 + 6 + type Identity = Awaited<ReturnType<typeof getIdentityData>>; 7 + let IdentityContext = createContext({ 8 + identity: null as Identity, 9 + mutate: (() => {}) as KeyedMutator<Identity>, 10 + }); 11 + export const useIdentityData = () => useContext(IdentityContext); 12 + export function IdentityContextProvider(props: { 13 + children: React.ReactNode; 14 + initialValue: Identity; 15 + }) { 16 + let { data: identity, mutate } = useSWR("identity", () => getIdentityData(), { 17 + fallbackData: props.initialValue, 18 + }); 19 + return ( 20 + <IdentityContext.Provider value={{ identity, mutate }}> 21 + {props.children} 22 + </IdentityContext.Provider> 23 + ); 24 + }
+13
components/IdentityProviderServer.tsx
··· 1 + import { getIdentityData } from "actions/getIdentityData"; 2 + import { IdentityContextProvider } from "./IdentityProvider"; 3 + 4 + export async function IdentityProviderServer(props: { 5 + children: React.ReactNode; 6 + }) { 7 + let identity = await getIdentityData(); 8 + return ( 9 + <IdentityContextProvider initialValue={identity}> 10 + {props.children} 11 + </IdentityContextProvider> 12 + ); 13 + }
+21
components/Layout.tsx
··· 85 85 ); 86 86 }; 87 87 88 + export const InputWithLabel = ( 89 + props: { 90 + label: string; 91 + } & JSX.IntrinsicElements["input"], 92 + ) => { 93 + let { label, ...inputProps } = props; 94 + return ( 95 + <div> 96 + <div className="input-with-border flex flex-col"> 97 + <label className="text-sm text-tertiary font-bold italic"> 98 + {props.label} 99 + <input 100 + {...inputProps} 101 + className={`appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 ${props.className}`} 102 + /> 103 + </label> 104 + </div> 105 + </div> 106 + ); 107 + }; 108 + 88 109 export const ShortcutKey = (props: { children: React.ReactNode }) => { 89 110 return ( 90 111 <span>
+23
components/LoginButton.tsx
··· 1 + "use client"; 2 + import { logout } from "actions/logout"; 3 + import { useIdentityData } from "./IdentityProvider"; 4 + import { Popover } from "./Popover"; 5 + import LoginForm from "app/login/LoginForm"; 6 + import { ButtonPrimary } from "./Buttons"; 7 + 8 + export function LoginButton() { 9 + let identityData = useIdentityData(); 10 + if (identityData.identity) return null; 11 + return ( 12 + <Popover 13 + asChild 14 + trigger={ 15 + <ButtonPrimary className="place-self-start text-sm"> 16 + Log In! 17 + </ButtonPrimary> 18 + } 19 + > 20 + <LoginForm /> 21 + </Popover> 22 + ); 23 + }
-2
components/MobileFooter.tsx
··· 7 7 import { HomeButton } from "./HomeButton"; 8 8 import { useEntitySetContext } from "./EntitySetProvider"; 9 9 import { HelpPopover } from "./HelpPopover"; 10 - import { CreateNewLeafletButton } from "app/home/CreateNewButton"; 11 10 import { Watermark } from "./Watermark"; 12 11 13 12 export function MobileFooter(props: { entityID: string }) { ··· 35 34 <HomeButton /> 36 35 <div className="flex flex-row gap-[6px] items-center "> 37 36 <HelpPopover /> 38 - <CreateNewLeafletButton /> 39 37 <ThemePopover entityID={props.entityID} /> 40 38 <ShareOptions rootEntity={props.entityID} /> 41 39 </div>
+2 -4
components/Pages/index.tsx
··· 28 28 import { MenuItem, Menu } from "../Layout"; 29 29 import { MoreOptionsTiny, CloseTiny, PaintSmall, ShareSmall } from "../Icons"; 30 30 import { HelpPopover } from "../HelpPopover"; 31 - import { CreateNewLeafletButton } from "app/home/CreateNewButton"; 32 31 import { scanIndex } from "src/replicache/utils"; 33 32 import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; 34 33 import { CardThemeProvider } from "../ThemeManager/ThemeProvider"; 35 34 import { PageShareMenu } from "./PageShareMenu"; 36 35 import { Watermark } from "components/Watermark"; 37 36 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 37 + import { LoginButton } from "components/LoginButton"; 38 38 39 39 export function Pages(props: { rootPage: string }) { 40 40 let rootPage = useEntity(props.rootPage, "root/page")[0]; ··· 65 65 <div className="flex flex-col justify-center gap-2 mr-4"> 66 66 <ShareOptions rootEntity={props.rootPage} /> 67 67 <LeafletOptions entityID={props.rootPage} /> 68 - <CreateNewLeafletButton /> 69 68 <HelpPopover /> 70 69 <hr className="text-border my-3" /> 71 70 <HomeButton /> 72 71 </div> 73 72 ) : ( 74 73 <div> 75 - {" "} 76 - <HomeButton />{" "} 74 + <HomeButton /> 77 75 </div> 78 76 )} 79 77 <Watermark />
+13 -3
components/Popover.tsx
··· 12 12 border?: string; 13 13 className?: string; 14 14 open?: boolean; 15 + onOpenChange?: (open: boolean) => void; 16 + asChild?: boolean; 15 17 }) => { 16 18 return ( 17 - <RadixPopover.Root open={props.open}> 18 - <RadixPopover.Trigger disabled={props.disabled}> 19 + <RadixPopover.Root open={props.open} onOpenChange={props.onOpenChange}> 20 + <RadixPopover.Trigger disabled={props.disabled} asChild={props.asChild}> 19 21 {props.trigger} 20 22 </RadixPopover.Trigger> 21 23 <RadixPopover.Portal> 22 24 <NestedCardThemeProvider> 23 25 <RadixPopover.Content 24 - className={`z-20 bg-bg-page border border-border rounded-md px-3 py-2 max-h-[var(--radix-popover-content-available-height)] overflow-y-scroll no-scrollbar shadow-md ${props.className}`} 26 + className={` 27 + z-20 bg-bg-page 28 + px-3 py-2 29 + max-w-[var(--radix-popover-content-available-width)] 30 + max-h-[var(--radix-popover-content-available-height)] 31 + border border-border rounded-md shadow-md 32 + overflow-y-scroll no-scrollbar 33 + ${props.className} 34 + `} 25 35 align={props.align ? props.align : "center"} 26 36 sideOffset={4} 27 37 collisionPadding={16}
+68 -64
components/ThemeManager/ThemeSetter.tsx
··· 222 222 Example Button 223 223 </div> 224 224 </div> 225 - {!props.home && ( 226 - <> 227 - <div className="flex flex-col mt-8 -mb-[6px] z-10"> 228 - <div 229 - className="themeLeafletControls 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))]" 230 - style={{ backgroundColor: "rgba(var(--bg-page, 0.6)" }} 231 - > 232 - <ColorPicker 233 - label="Page" 234 - alpha 235 - value={pageValue} 236 - setValue={set("theme/card-background")} 237 - thisPicker={"page"} 238 - openPicker={openPicker} 239 - setOpenPicker={setOpenPicker} 240 - closePicker={() => setOpenPicker("null")} 241 - /> 242 - <ColorPicker 243 - label="Text" 244 - value={primaryValue} 245 - setValue={set("theme/primary")} 246 - thisPicker={"text"} 247 - openPicker={openPicker} 248 - setOpenPicker={setOpenPicker} 249 - closePicker={() => setOpenPicker("null")} 250 - /> 251 - </div> 252 - <SectionArrow 253 - fill={theme.colors["primary"]} 254 - stroke={theme.colors["bg-page"]} 255 - className=" ml-2" 256 - /> 257 - </div> 258 225 259 - <div 260 - onClick={(e) => { 261 - e.currentTarget === e.target && setOpenPicker("page"); 262 - }} 263 - className="rounded-t-lg cursor-pointer p-2 border border-border border-b-transparent shadow-md text-primary" 264 - style={{ 265 - backgroundColor: 266 - "rgba(var(--bg-page), var(--bg-page-alpha))", 267 - }} 268 - > 269 - <p 270 - onClick={() => { 271 - setOpenPicker("text"); 272 - }} 273 - className=" cursor-pointer font-bold w-fit" 274 - > 275 - Hello! 276 - </p> 277 - <small onClick={() => setOpenPicker("text")}> 278 - Welcome to{" "} 279 - <span className="font-bold text-accent-contrast"> 280 - Leaflet 281 - </span> 282 - . It&apos;s a super easy and fun way to make, share, and 283 - collab on little bits of paper 284 - </small> 285 - </div> 286 - </> 287 - )} 226 + <div className="flex flex-col mt-8 -mb-[6px] z-10"> 227 + <div 228 + className="themeLeafletControls 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))]" 229 + style={{ backgroundColor: "rgba(var(--bg-page, 0.6)" }} 230 + > 231 + <ColorPicker 232 + label={props.home ? "Menu" : "Page"} 233 + alpha 234 + value={pageValue} 235 + setValue={set("theme/card-background")} 236 + thisPicker={"page"} 237 + openPicker={openPicker} 238 + setOpenPicker={setOpenPicker} 239 + closePicker={() => setOpenPicker("null")} 240 + /> 241 + <ColorPicker 242 + label={props.home ? "Menu Text" : "Page"} 243 + value={primaryValue} 244 + setValue={set("theme/primary")} 245 + thisPicker={"text"} 246 + openPicker={openPicker} 247 + setOpenPicker={setOpenPicker} 248 + closePicker={() => setOpenPicker("null")} 249 + /> 250 + </div> 251 + <SectionArrow 252 + fill={theme.colors["primary"]} 253 + stroke={theme.colors["bg-page"]} 254 + className=" ml-2" 255 + /> 256 + </div> 257 + 258 + <SamplePage setOpenPicker={setOpenPicker} home={props.home} /> 288 259 </div> 289 - <WatermarkSetter entityID={props.entityID} /> 260 + {!props.home && <WatermarkSetter entityID={props.entityID} />} 290 261 </div> 291 262 <Popover.Arrow asChild width={16} height={8} viewBox="0 0 16 8"> 292 263 <PopoverArrow ··· 333 304 </label> 334 305 ); 335 306 } 307 + 308 + const SamplePage = (props: { 309 + home: boolean | undefined; 310 + setOpenPicker: (picker: "page" | "text") => void; 311 + }) => { 312 + return ( 313 + <div 314 + onClick={(e) => { 315 + e.currentTarget === e.target && props.setOpenPicker("page"); 316 + }} 317 + className={`${props.home ? "rounded-md " : "rounded-t-lg "} cursor-pointer p-2 border border-border border-b-transparent shadow-md text-primary`} 318 + style={{ 319 + backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 320 + }} 321 + > 322 + <p 323 + onClick={() => { 324 + props.setOpenPicker("text"); 325 + }} 326 + className=" cursor-pointer font-bold w-fit" 327 + > 328 + Hello! 329 + </p> 330 + <small onClick={() => props.setOpenPicker("text")}> 331 + Welcome to{" "} 332 + <span className="font-bold text-accent-contrast">Leaflet</span>. 333 + It&apos;s a super easy and fun way to make, share, and collab on little 334 + bits of paper 335 + </small> 336 + </div> 337 + ); 338 + }; 339 + 336 340 let thumbStyle = 337 341 "w-4 h-4 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C,_inset_0_0_0_1px_#8C8C8C]"; 338 342 ··· 602 606 </label> 603 607 </div> 604 608 {open && ( 605 - <div className="bgImageAndColorPicker w-full flex flex-col gap-2 pb-2"> 609 + <div className="bgImageAndColorPicker w-full flex flex-col gap-2 "> 606 610 <SpectrumColorPicker 607 611 value={bgColor} 608 612 onChange={setColorAttribute(
+4 -1
components/utils/AddLeafletToHomepage.tsx
··· 1 1 "use client"; 2 2 3 3 import { addDocToHome } from "app/home/storage"; 4 + import { useIdentityData } from "components/IdentityProvider"; 4 5 import { useEffect } from "react"; 5 6 import { useReplicache } from "src/replicache"; 6 7 7 8 export function AddLeafletToHomepage() { 8 9 let { permission_token } = useReplicache(); 10 + let { identity } = useIdentityData(); 9 11 useEffect(() => { 12 + if (identity) return; 10 13 if (permission_token.permission_token_rights[0].write) { 11 14 try { 12 15 addDocToHome(permission_token); 13 16 } catch (e) {} 14 17 } 15 - }, [permission_token]); 18 + }, [permission_token, identity]); 16 19 return null; 17 20 }
+34 -13
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { entity_sets, entities, email_subscriptions_to_entity, permission_tokens, identities, facts, permission_token_rights } from "./schema"; 2 + import { entity_sets, entities, facts, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, permission_token_on_homepage, permission_token_rights } from "./schema"; 3 3 4 4 export const entitiesRelations = relations(entities, ({one, many}) => ({ 5 5 entity_set: one(entity_sets, { 6 6 fields: [entities.set], 7 7 references: [entity_sets.id] 8 8 }), 9 - email_subscriptions_to_entities: many(email_subscriptions_to_entity), 10 - permission_tokens: many(permission_tokens), 11 9 facts: many(facts), 10 + permission_tokens: many(permission_tokens), 11 + email_subscriptions_to_entities: many(email_subscriptions_to_entity), 12 12 })); 13 13 14 14 export const entity_setsRelations = relations(entity_sets, ({many}) => ({ ··· 16 16 permission_token_rights: many(permission_token_rights), 17 17 })); 18 18 19 - export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ 19 + export const factsRelations = relations(facts, ({one}) => ({ 20 20 entity: one(entities, { 21 - fields: [email_subscriptions_to_entity.entity], 21 + fields: [facts.entity], 22 22 references: [entities.id] 23 23 }), 24 + })); 25 + 26 + export const identitiesRelations = relations(identities, ({one, many}) => ({ 24 27 permission_token: one(permission_tokens, { 25 - fields: [email_subscriptions_to_entity.token], 28 + fields: [identities.home_page], 26 29 references: [permission_tokens.id] 27 30 }), 31 + email_auth_tokens: many(email_auth_tokens), 32 + permission_token_on_homepages: many(permission_token_on_homepage), 28 33 })); 29 34 30 35 export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({ 31 - email_subscriptions_to_entities: many(email_subscriptions_to_entity), 32 36 identities: many(identities), 33 37 entity: one(entities, { 34 38 fields: [permission_tokens.root_entity], 35 39 references: [entities.id] 36 40 }), 41 + email_subscriptions_to_entities: many(email_subscriptions_to_entity), 42 + permission_token_on_homepages: many(permission_token_on_homepage), 37 43 permission_token_rights: many(permission_token_rights), 38 44 })); 39 45 40 - export const identitiesRelations = relations(identities, ({one}) => ({ 46 + export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ 47 + entity: one(entities, { 48 + fields: [email_subscriptions_to_entity.entity], 49 + references: [entities.id] 50 + }), 41 51 permission_token: one(permission_tokens, { 42 - fields: [identities.home_page], 52 + fields: [email_subscriptions_to_entity.token], 43 53 references: [permission_tokens.id] 44 54 }), 45 55 })); 46 56 47 - export const factsRelations = relations(facts, ({one}) => ({ 48 - entity: one(entities, { 49 - fields: [facts.entity], 50 - references: [entities.id] 57 + export const email_auth_tokensRelations = relations(email_auth_tokens, ({one}) => ({ 58 + identity: one(identities, { 59 + fields: [email_auth_tokens.identity], 60 + references: [identities.id] 61 + }), 62 + })); 63 + 64 + export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({ 65 + identity: one(identities, { 66 + fields: [permission_token_on_homepage.identity], 67 + references: [identities.id] 68 + }), 69 + permission_token: one(permission_tokens, { 70 + fields: [permission_token_on_homepage.token], 71 + references: [permission_tokens.id] 51 72 }), 52 73 })); 53 74
+42 -21
drizzle/schema.ts
··· 1 - import { pgTable, pgEnum, text, bigint, foreignKey, uuid, timestamp, boolean, jsonb, primaryKey } from "drizzle-orm/pg-core" 1 + import { pgTable, foreignKey, pgEnum, uuid, timestamp, text, jsonb, bigint, boolean, primaryKey } from "drizzle-orm/pg-core" 2 2 import { sql } from "drizzle-orm" 3 3 4 4 export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) ··· 13 13 export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in']) 14 14 15 15 16 + export const entities = pgTable("entities", { 17 + id: uuid("id").primaryKey().notNull(), 18 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 19 + set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), 20 + }); 21 + 22 + export const facts = pgTable("facts", { 23 + id: uuid("id").primaryKey().notNull(), 24 + entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ), 25 + attribute: text("attribute").notNull(), 26 + data: jsonb("data").notNull(), 27 + created_at: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), 28 + updated_at: timestamp("updated_at", { mode: 'string' }), 29 + // You can use { mode: "bigint" } if numbers are exceeding js number limitations 30 + version: bigint("version", { mode: "number" }).default(0).notNull(), 31 + }); 32 + 16 33 export const replicache_clients = pgTable("replicache_clients", { 17 34 client_id: text("client_id").primaryKey().notNull(), 18 35 client_group: text("client_group").notNull(), ··· 20 37 last_mutation: bigint("last_mutation", { mode: "number" }).notNull(), 21 38 }); 22 39 23 - export const entities = pgTable("entities", { 24 - id: uuid("id").primaryKey().notNull(), 40 + export const entity_sets = pgTable("entity_sets", { 41 + id: uuid("id").defaultRandom().primaryKey().notNull(), 25 42 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 26 - set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), 27 43 }); 28 44 29 - export const entity_sets = pgTable("entity_sets", { 45 + export const identities = pgTable("identities", { 30 46 id: uuid("id").defaultRandom().primaryKey().notNull(), 31 47 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 48 + home_page: uuid("home_page").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 49 + email: text("email"), 50 + }); 51 + 52 + export const permission_tokens = pgTable("permission_tokens", { 53 + id: uuid("id").defaultRandom().primaryKey().notNull(), 54 + root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 32 55 }); 33 56 34 57 export const email_subscriptions_to_entity = pgTable("email_subscriptions_to_entity", { ··· 41 64 confirmation_code: text("confirmation_code").notNull(), 42 65 }); 43 66 44 - export const identities = pgTable("identities", { 67 + export const email_auth_tokens = pgTable("email_auth_tokens", { 45 68 id: uuid("id").defaultRandom().primaryKey().notNull(), 46 69 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 47 - home_page: uuid("home_page").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 70 + confirmed: boolean("confirmed").default(false).notNull(), 71 + email: text("email").notNull(), 72 + confirmation_code: text("confirmation_code").notNull(), 73 + identity: uuid("identity").references(() => identities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 48 74 }); 49 75 50 - export const permission_tokens = pgTable("permission_tokens", { 51 - id: uuid("id").defaultRandom().primaryKey().notNull(), 52 - root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 53 - }); 54 - 55 - export const facts = pgTable("facts", { 56 - id: uuid("id").primaryKey().notNull(), 57 - entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ), 58 - attribute: text("attribute").notNull(), 59 - data: jsonb("data").notNull(), 60 - created_at: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), 61 - updated_at: timestamp("updated_at", { mode: 'string' }), 62 - // You can use { mode: "bigint" } if numbers are exceeding js number limitations 63 - version: bigint("version", { mode: "number" }).default(0).notNull(), 76 + export const permission_token_on_homepage = pgTable("permission_token_on_homepage", { 77 + token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 78 + identity: uuid("identity").notNull().references(() => identities.id, { onDelete: "cascade" } ), 79 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 80 + }, 81 + (table) => { 82 + return { 83 + permission_token_creator_pkey: primaryKey({ columns: [table.token, table.identity], name: "permission_token_creator_pkey"}), 84 + } 64 85 }); 65 86 66 87 export const permission_token_rights = pgTable("permission_token_rights", {
+3 -1
src/replicache/index.tsx
··· 54 54 token: PermissionToken; 55 55 name: string; 56 56 children: React.ReactNode; 57 + initialFactsOnly?: boolean; 57 58 }) { 58 59 let [rep, setRep] = useState<null | Replicache<ReplicacheMutators>>(null); 59 60 useEffect(() => { 61 + if (props.initialFactsOnly) return; 60 62 let supabase = supabaseBrowserClient(); 61 63 let newRep = new Replicache({ 62 64 pushDelay: 500, ··· 109 111 setRep(null); 110 112 channel.unsubscribe(); 111 113 }; 112 - }, [props.name]); 114 + }, [props.name, props.initialFactsOnly, props.token]); 113 115 return ( 114 116 <ReplicacheContext.Provider 115 117 value={{
+96
supabase/database.types.ts
··· 34 34 } 35 35 public: { 36 36 Tables: { 37 + email_auth_tokens: { 38 + Row: { 39 + confirmation_code: string 40 + confirmed: boolean 41 + created_at: string 42 + email: string 43 + id: string 44 + identity: string | null 45 + } 46 + Insert: { 47 + confirmation_code: string 48 + confirmed?: boolean 49 + created_at?: string 50 + email: string 51 + id?: string 52 + identity?: string | null 53 + } 54 + Update: { 55 + confirmation_code?: string 56 + confirmed?: boolean 57 + created_at?: string 58 + email?: string 59 + id?: string 60 + identity?: string | null 61 + } 62 + Relationships: [ 63 + { 64 + foreignKeyName: "email_auth_tokens_identity_fkey" 65 + columns: ["identity"] 66 + isOneToOne: false 67 + referencedRelation: "identities" 68 + referencedColumns: ["id"] 69 + }, 70 + ] 71 + } 37 72 email_subscriptions_to_entity: { 38 73 Row: { 39 74 confirmation_code: string ··· 161 196 identities: { 162 197 Row: { 163 198 created_at: string 199 + email: string | null 164 200 home_page: string 165 201 id: string 166 202 } 167 203 Insert: { 168 204 created_at?: string 205 + email?: string | null 169 206 home_page: string 170 207 id?: string 171 208 } 172 209 Update: { 173 210 created_at?: string 211 + email?: string | null 174 212 home_page?: string 175 213 id?: string 176 214 } ··· 184 222 }, 185 223 ] 186 224 } 225 + permission_token_on_homepage: { 226 + Row: { 227 + created_at: string 228 + identity: string 229 + token: string 230 + } 231 + Insert: { 232 + created_at?: string 233 + identity: string 234 + token: string 235 + } 236 + Update: { 237 + created_at?: string 238 + identity?: string 239 + token?: string 240 + } 241 + Relationships: [ 242 + { 243 + foreignKeyName: "permission_token_creator_identity_fkey" 244 + columns: ["identity"] 245 + isOneToOne: false 246 + referencedRelation: "identities" 247 + referencedColumns: ["id"] 248 + }, 249 + { 250 + foreignKeyName: "permission_token_creator_token_fkey" 251 + columns: ["token"] 252 + isOneToOne: false 253 + referencedRelation: "permission_tokens" 254 + referencedColumns: ["id"] 255 + }, 256 + ] 257 + } 187 258 permission_token_rights: { 188 259 Row: { 189 260 change_entity_set: boolean ··· 287 358 id: string 288 359 updated_at: string | null 289 360 version: number 361 + }[] 362 + } 363 + get_facts_for_roots: { 364 + Args: { 365 + roots: string[] 366 + max_depth: number 367 + } 368 + Returns: { 369 + root_id: string 370 + id: string 371 + entity: string 372 + attribute: string 373 + data: Json 374 + created_at: string 375 + updated_at: string 376 + version: number 377 + }[] 378 + } 379 + get_facts_with_depth: { 380 + Args: { 381 + root: string 382 + max_depth: number 383 + } 384 + Returns: { 385 + like: unknown 290 386 }[] 291 387 } 292 388 }
+62
supabase/migrations/20241210031519_add_more_identity_tables.sql
··· 1 + create table "public"."email_auth_tokens" ( 2 + "id" uuid not null default gen_random_uuid(), 3 + "created_at" timestamp with time zone not null default now(), 4 + "confirmed" boolean not null default false, 5 + "email" text not null, 6 + "confirmation_code" text not null, 7 + "identity" uuid 8 + ); 9 + 10 + alter table "public"."email_auth_tokens" enable row level security; 11 + 12 + alter table "public"."identities" add column "email" text; 13 + 14 + CREATE UNIQUE INDEX email_auth_tokens_pkey ON public.email_auth_tokens USING btree (id); 15 + 16 + alter table "public"."email_auth_tokens" add constraint "email_auth_tokens_pkey" PRIMARY KEY using index "email_auth_tokens_pkey"; 17 + 18 + alter table "public"."email_auth_tokens" add constraint "email_auth_tokens_identity_fkey" FOREIGN KEY (identity) REFERENCES identities(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; 19 + 20 + alter table "public"."email_auth_tokens" validate constraint "email_auth_tokens_identity_fkey"; 21 + 22 + grant delete on table "public"."email_auth_tokens" to "anon"; 23 + 24 + grant insert on table "public"."email_auth_tokens" to "anon"; 25 + 26 + grant references on table "public"."email_auth_tokens" to "anon"; 27 + 28 + grant select on table "public"."email_auth_tokens" to "anon"; 29 + 30 + grant trigger on table "public"."email_auth_tokens" to "anon"; 31 + 32 + grant truncate on table "public"."email_auth_tokens" to "anon"; 33 + 34 + grant update on table "public"."email_auth_tokens" to "anon"; 35 + 36 + grant delete on table "public"."email_auth_tokens" to "authenticated"; 37 + 38 + grant insert on table "public"."email_auth_tokens" to "authenticated"; 39 + 40 + grant references on table "public"."email_auth_tokens" to "authenticated"; 41 + 42 + grant select on table "public"."email_auth_tokens" to "authenticated"; 43 + 44 + grant trigger on table "public"."email_auth_tokens" to "authenticated"; 45 + 46 + grant truncate on table "public"."email_auth_tokens" to "authenticated"; 47 + 48 + grant update on table "public"."email_auth_tokens" to "authenticated"; 49 + 50 + grant delete on table "public"."email_auth_tokens" to "service_role"; 51 + 52 + grant insert on table "public"."email_auth_tokens" to "service_role"; 53 + 54 + grant references on table "public"."email_auth_tokens" to "service_role"; 55 + 56 + grant select on table "public"."email_auth_tokens" to "service_role"; 57 + 58 + grant trigger on table "public"."email_auth_tokens" to "service_role"; 59 + 60 + grant truncate on table "public"."email_auth_tokens" to "service_role"; 61 + 62 + grant update on table "public"."email_auth_tokens" to "service_role";
+45
supabase/migrations/20241213050822_add_more_get_fact_functions.sql
··· 1 + CREATE OR REPLACE FUNCTION public.get_facts_with_depth(root uuid, max_depth integer) 2 + RETURNS TABLE("like" facts) 3 + LANGUAGE sql 4 + AS $function$WITH RECURSIVE all_facts as ( 5 + -- Base case: start with root level (depth 0) 6 + select 7 + *, 8 + 0 as depth 9 + from 10 + facts 11 + where 12 + entity = root 13 + 14 + union 15 + 16 + -- Recursive case: join with previous level and increment depth 17 + select 18 + f.*, 19 + f1.depth + 1 as depth 20 + from 21 + facts f 22 + inner join all_facts f1 on ( 23 + uuid(f1.data ->> 'value') = f.entity 24 + ) 25 + where 26 + (f1.data ->> 'type' in ('reference', 'ordered-reference', 'spatial-reference')) 27 + and f1.depth < max_depth -- Add depth limit parameter 28 + ) 29 + select 30 + id, entity, attribute, data, created_at, updated_at, version 31 + from 32 + all_facts;$function$ 33 + ; 34 + 35 + CREATE OR REPLACE FUNCTION public.get_facts_for_roots(roots uuid[], max_depth integer) 36 + RETURNS TABLE(root_id uuid, id uuid, entity uuid, attribute text, data jsonb, created_at timestamp without time zone, updated_at timestamp without time zone, version bigint) 37 + LANGUAGE sql 38 + AS $function$ 39 + SELECT 40 + root_id, 41 + f.* 42 + FROM unnest(roots) AS root_id 43 + CROSS JOIN LATERAL get_facts_with_depth(root_id, max_depth) f; 44 + $function$ 45 + ;