init commit,

This commit is contained in:
louiscklaw
2025-05-28 09:55:51 +08:00
commit efe70ceb69
8042 changed files with 951668 additions and 0 deletions

View File

@@ -0,0 +1,144 @@
/**
* @file Auth step 1 page
*/
import HCaptcha from "@hcaptcha/react-hcaptcha";
import {zodResolver} from "@hookform/resolvers/zod";
import {IonButton, IonIcon, IonInput} from "@ionic/react";
import {paperPlaneOutline, paperPlaneSharp} from "ionicons/icons";
import {FC, useRef} from "react";
import {Controller, useForm} from "react-hook-form";
import {useHistory} from "react-router-dom";
import {z} from "zod";
import {AuthContainer} from "~/components/auth-container";
import {SupplementalError} from "~/components/supplemental-error";
import {useEphemeralStore} from "~/lib/stores/ephemeral";
import {usePersistentStore} from "~/lib/stores/persistent";
import {client} from "~/lib/supabase";
import {Theme, UserMetadata} from "~/lib/types";
import {HCAPTCHA_SITE_KEY} from "~/lib/vars";
/**
* Form schema
*/
const formSchema = z.object({
email: z.string().email(),
captchaToken: z
.string({
// eslint-disable-next-line camelcase
required_error: "Please complete the challenge",
})
.min(1, "Please complete the challenge"),
});
/**
* Form schema type
*/
type FormSchema = z.infer<typeof formSchema>;
/**
* Auth step 1 component
* @returns JSX
*/
export const Step1: FC = () => {
// Hooks
const captcha = useRef<HCaptcha>(null);
const setEmail = useEphemeralStore(state => state.setEmail);
const theme = usePersistentStore(state => state.theme);
const history = useHistory();
const {control, handleSubmit, reset} = useForm<FormSchema>({
resolver: zodResolver(formSchema),
});
// Methods
/**
* Form submit handler
* @param form Form data
*/
const onSubmit = async (form: FormSchema) => {
// Store the email for later
setEmail(form.email);
// Begin the log in process
const {error} = await client.auth.signInWithOtp({
email: form.email,
options: {
captchaToken: form.captchaToken,
emailRedirectTo: new URL("/auth/2", window.location.origin).toString(),
data: {
acceptedTerms: false,
} as UserMetadata,
},
});
// Handle the error
if (error !== null) {
// Partially reset the form
reset({
email: form.email,
});
// Reset the captcha
captcha.current?.resetCaptcha();
return;
}
// Go to the next step
history.push("/auth/2");
};
return (
<AuthContainer back={true}>
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="email"
render={({
field: {onChange, onBlur, value},
fieldState: {error, isTouched, invalid},
}) => (
<IonInput
className={`min-w-64 ${(invalid || isTouched) && "ion-touched"} ${
invalid && "ion-invalid"
} ${!invalid && isTouched && "ion-valid"}`}
errorText={error?.message}
fill="outline"
label="Email"
labelPlacement="floating"
onIonBlur={onBlur}
onIonInput={onChange}
type="email"
value={value}
/>
)}
/>
<Controller
control={control}
name="captchaToken"
render={({field: {onChange}, fieldState: {error}}) => (
<div className="py-4">
<HCaptcha
onVerify={token => onChange(token)}
ref={captcha}
sitekey={HCAPTCHA_SITE_KEY}
theme={theme === Theme.DARK ? "dark" : "light"}
/>
<SupplementalError error={error?.message} />
</div>
)}
/>
<IonButton
className="mb-0 mt-4 mx-0 overflow-hidden rounded-lg w-full"
expand="full"
type="submit"
>
<IonIcon slot="start" ios={paperPlaneOutline} md={paperPlaneSharp} />
Send Login Code
</IonButton>
</form>
</AuthContainer>
);
};

View File

@@ -0,0 +1,138 @@
/**
* @file Auth step 2 page
*/
import {zodResolver} from "@hookform/resolvers/zod";
import {IonButton, IonIcon, IonInput} from "@ionic/react";
import {checkmarkOutline, checkmarkSharp} from "ionicons/icons";
import {FC} from "react";
import {Controller, useForm} from "react-hook-form";
import {useHistory} from "react-router-dom";
import {z} from "zod";
import {AuthContainer} from "~/components/auth-container";
import {useEphemeralStore} from "~/lib/stores/ephemeral";
import {client} from "~/lib/supabase";
import {UserMetadata} from "~/lib/types";
/**
* Failed to login message metadata symbol
*/
const FAILED_TO_LOGIN_MESSAGE_METADATA_SYMBOL = Symbol("auth.failed-to-login");
/**
* Form schema
*/
const formSchema = z.object({
code: z
.string()
.min(1)
.refine(value => /^\d+$/.test(value), {
message: "Invalid code",
}),
});
/**
* Form schema type
*/
type FormSchema = z.infer<typeof formSchema>;
/**
* Auth step 2 component
* @returns JSX
*/
export const Step2: FC = () => {
// Hooks
const email = useEphemeralStore(state => state.email);
const setMessage = useEphemeralStore(state => state.setMessage);
const history = useHistory();
const {control, handleSubmit, reset} = useForm<FormSchema>({
resolver: zodResolver(formSchema),
});
// Methods
/**
* Verify the code
* @param code Code
*/
const verify = async (code: string) => {
// Log in
const {data, error} = await client.auth.verifyOtp({
email: email!,
token: code,
type: "email",
});
// Handle the error
if (error !== null) {
// Reset the form
reset();
// Display the message
setMessage({
symbol: FAILED_TO_LOGIN_MESSAGE_METADATA_SYMBOL,
name: "Failed to log in",
description: error.message,
});
// Go back to the previous step
history.goBack();
return;
}
// Get the user metadata
const userMetadata = data!.user!.user_metadata as UserMetadata;
// Go to the terms and conditions if the user hasn't accepted them
history.push(userMetadata.acceptedTerms ? "/nearby" : "/auth/3");
};
/**
* Form submit handler
* @param data Form data
* @returns Nothing
*/
const onSubmit = async (data: FormSchema) => await verify(data.code);
return (
<AuthContainer back={true}>
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="code"
render={({
field: {onChange, onBlur, value},
fieldState: {error, isTouched, invalid},
}) => (
<IonInput
className={`min-w-64 mb-4 ${
(invalid || isTouched) && "ion-touched"
} ${invalid && "ion-invalid"} ${
!invalid && isTouched && "ion-valid"
}`}
errorText={error?.message}
fill="outline"
label="Code"
labelPlacement="floating"
onIonBlur={onBlur}
onIonChange={onChange}
type="text"
value={value}
/>
)}
/>
<IonButton
className="mb-0 mt-4 mx-0 overflow-hidden rounded-lg w-full"
expand="full"
type="submit"
>
<IonIcon slot="start" ios={checkmarkOutline} md={checkmarkSharp} />
Verify Code
</IonButton>
</form>
</AuthContainer>
);
};

View File

@@ -0,0 +1,115 @@
/**
* @file Auth step 3 page
*/
import {IonButton, IonIcon, IonRouterLink} from "@ionic/react";
import {
checkmarkOutline,
checkmarkSharp,
closeOutline,
closeSharp,
} from "ionicons/icons";
import {FC, FormEvent} from "react";
import {useHistory} from "react-router-dom";
import {AuthContainer} from "~/components/auth-container";
import {client} from "~/lib/supabase";
import {UserMetadata} from "~/lib/types";
/**
* Auth step 3 component
* @returns JSX
*/
export const Step3: FC = () => {
// Hooks
const history = useHistory();
// Methods
/**
* Form submit handler
* @param event Form event
* @returns Nothing
*/
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
// Update the user's terms and conditions agreement
const {error} = await client.auth.updateUser({
data: {
acceptedTerms: true,
} as UserMetadata,
});
// Handle the error
if (error !== null) {
return;
}
// Go to nearby
history.push("/nearby");
};
/**
* Reject button click handler
*/
const reject = () => {
// Log out
client.auth.signOut();
// Go to home
history.push("/");
};
return (
<AuthContainer back={true}>
<form onSubmit={onSubmit}>
<p>
You agree to the{" "}
<IonRouterLink
className="font-bold underline"
routerLink="/terms-and-conditions"
>
terms and conditions
</IonRouterLink>{" "}
and{" "}
<IonRouterLink
className="font-bold underline"
routerLink="/privacy-policy"
>
privacy policy
</IonRouterLink>{" "}
of this app. This includes (but is not limited to):
</p>
<ul className="list-disc ml-4 my-1">
<li>
We reserve the right to remove any content and ban any user at any
time at our own discretion.
</li>
<li>Violating the terms and conditions will result in a ban.</li>
<li>
We collect your geolocation data to filter content to your location.
</li>
</ul>
<IonButton
className="mb-0 mt-4 mx-0 overflow-hidden rounded-lg w-full"
expand="full"
type="submit"
>
<IonIcon slot="start" ios={checkmarkOutline} md={checkmarkSharp} />
Agreee
</IonButton>
<IonButton
className="mb-0 mt-4 mx-0 overflow-hidden rounded-lg w-full"
color="danger"
expand="full"
onClick={reject}
>
<IonIcon slot="start" ios={closeOutline} md={closeSharp} />
Reject
</IonButton>
</form>
</AuthContainer>
);
};

View File

@@ -0,0 +1,54 @@
/**
* @file Error page
*/
import {IonButton, IonContent, IonIcon, IonPage} from "@ionic/react";
import {homeOutline, homeSharp} from "ionicons/icons";
import {FC} from "react";
import {Header} from "~/components/header";
/**
* Error page props
*/
interface ErrorProps {
/**
* Error name
*/
name: string;
/**
* Error description
*/
description: string;
/**
* Whether or not to show the home button
*/
homeButton: boolean;
}
/**
* Error page
* @returns JSX
*/
export const Error: FC<ErrorProps> = ({name, description, homeButton}) => {
return (
<IonPage>
<Header />
<IonContent>
<div className="flex flex-col h-full items-center justify-center text-center w-full">
<h1 className="text-6xl">{name}</h1>
<p className="my-4 text-xl">{description}</p>
{homeButton && (
<IonButton routerLink="/">
<IonIcon slot="start" ios={homeOutline} md={homeSharp} />
Take me home
</IonButton>
)}
</div>
</IonContent>
</IonPage>
);
};

View File

@@ -0,0 +1,3 @@
.snapContent::part(scroll) {
@apply <md:snap-y <md:snap-mandatory;
}

View File

@@ -0,0 +1,221 @@
/**
* @file Index page
*/
import {IonButton, IonContent, IonIcon, IonPage} from "@ionic/react";
import {
documentTextOutline,
documentTextSharp,
helpCircleOutline,
helpCircleSharp,
navigateCircleOutline,
navigateCircleSharp,
shieldOutline,
shieldSharp,
} from "ionicons/icons";
import {FC, useEffect, useRef} from "react";
import {useLocation} from "react-router-dom";
import {useMeasure} from "react-use";
import {Header} from "~/components/header";
import {usePersistentStore} from "~/lib/stores/persistent";
import {Theme} from "~/lib/types";
import styles from "~/pages/index.module.css";
/**
* Number of frames
*/
const FRAME_COUNT = 6;
/**
* Index page
* @returns JSX
*/
export const Index: FC = () => {
// Hooks
const theme = usePersistentStore(state => state.theme);
const [containerRef, {height, width}] = useMeasure();
const contentRef = useRef<HTMLIonContentElement>(null);
const location = useLocation();
// Effects
useEffect(() => {
setTimeout(async () => {
if (contentRef.current === null) {
return;
}
// Scroll back to the top
await contentRef.current.scrollToTop(0);
}, 50);
}, [location.pathname]);
return (
<IonPage ref={containerRef}>
<Header />
<IonContent
className={styles.snapContent}
style={{
"--window-height": `${height}px`,
}}
ref={contentRef}
>
{/* Background */}
<div
className="-z-1 absolute left-0 top-0 w-full"
style={{
height: `calc(100vh * ${FRAME_COUNT})`,
}}
>
<div
className={`absolute bg-gradient-to-b w-full h-full ${
theme === Theme.DARK
? "from-black to-primary-500"
: "from-white to-primary-600"
}`}
/>
<svg
className="absolute h-full w-full"
viewBox={`0 0 ${width} ${FRAME_COUNT * height}`}
xmlns="http://www.w3.org/2000/svg"
>
<filter id="noiseFilter">
<feTurbulence
type="fractalNoise"
baseFrequency="10"
numOctaves="1"
stitchTiles="stitch"
/>
</filter>
<rect
opacity={theme === Theme.DARK ? "0.2" : "0.4"}
width="100%"
height="100%"
filter="url(#noiseFilter)"
/>
</svg>
</div>
{/* First frame */}
<div className="animate-fade-in animate-ease-in-out flex flex-col h-[var(--window-height)] items-center justify-center px-6 text-center w-full snap-center">
<div className="my-2">
<h1 className="mb-1 text-7xl font-bold tracking-[0.2em]">BEACON</h1>
<h2 className="mt-1 text-xl">A location-based social network.</h2>
</div>
<IonButton
className="my-2"
color="primary"
fill="outline"
routerLink="/auth/1"
>
<IonIcon
slot="start"
ios={navigateCircleOutline}
md={navigateCircleSharp}
/>
Get Started
</IonButton>
</div>
{/* Second frame */}
<div className="flex flex-col h-[var(--window-height)] items-center justify-center px-6 text-center w-full snap-center">
<h2 className="mb-1 text-4xl">1. Create A Post</h2>
<h3 className="mt-1 text-m">
Every post can only be seen by other users nearby - you decide how
close they need to be.
</h3>
</div>
{/* Third frame */}
<div className="flex flex-col h-[var(--window-height)] items-center justify-center px-6 text-center w-full snap-center">
<h2 className="mb-1 text-4xl">2. View Other Posts</h2>
<h3 className="mt-1 text-m">
View nearby posts and interact with them by commenting, upvoting,
and downvoting them.
</h3>
</div>
{/* Fourth frame */}
<div className="flex flex-col h-[var(--window-height)] items-center justify-center px-6 text-center w-full snap-center">
<h2 className="mb-1 text-4xl">3. Remain Anonymous</h2>
<h3 className="mt-1 text-m">
You can choose to remain anonymous when creating posts and
commenting on other posts.
</h3>
</div>
{/* Fifth frame */}
<div className="flex flex-col h-[var(--window-height)] items-center justify-center px-6 text-center w-full snap-center">
<div className="my-2">
<h2 className="text-4xl">
So what are you waiting for? Get started now!
</h2>
</div>
<IonButton
className="my-2"
color="dark"
fill="outline"
routerLink="/auth/1"
>
<IonIcon
slot="start"
ios={navigateCircleOutline}
md={navigateCircleSharp}
/>
Get Started
</IonButton>
</div>
{/* Sixth frame */}
<div className="flex flex-col h-[var(--window-height)] items-center justify-center px-6 text-center w-full snap-center">
<div className="my-2">
<h2 className="text-4xl">Other Things</h2>
</div>
<IonButton
className="my-2"
color="dark"
fill="outline"
routerLink="/faq"
>
<IonIcon
slot="start"
ios={helpCircleOutline}
md={helpCircleSharp}
/>
Frequently Asked Questions
</IonButton>
<IonButton
className="my-2"
color="dark"
fill="outline"
routerLink="/terms-and-conditions"
>
<IonIcon
slot="start"
ios={documentTextOutline}
md={documentTextSharp}
/>
Terms and Conditions
</IonButton>
<IonButton
className="my-2"
color="dark"
fill="outline"
routerLink="/privacy-policy"
>
<IonIcon slot="start" ios={shieldOutline} md={shieldSharp} />
Privacy Policy
</IonButton>
</div>
</IonContent>
</IonPage>
);
};

View File

@@ -0,0 +1,72 @@
/**
* @file Markdown page
*/
import {
IonBackButton,
IonButtons,
IonContent,
IonHeader,
IonPage,
IonTitle,
IonToolbar,
} from "@ionic/react";
import {FC} from "react";
import {useAsync} from "react-use";
import {Markdown as MarkdownRenderer} from "~/components/markdown";
/**
* Markdown page props
*/
interface MarkdownProps {
/**
* Page title
*/
title: string;
/**
* Markdown URL (relative or absolute)
*/
url: string;
}
/**
* Markdown page
* @returns JSX
*/
export const Markdown: FC<MarkdownProps> = ({title, url}) => {
// Hooks
const markdown = useAsync(async () => {
// Fetch the markdown
const response = await fetch(url);
// Convert the response to text
return await response.text();
});
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/" />
</IonButtons>
<IonTitle>{title}</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<MarkdownRenderer
className="break-anywhere h-full overflow-auto p-2 text-wrap w-full"
raw={
markdown.loading
? "Loading..."
: markdown.value ?? `Failed to load ${title}.`
}
/>
</IonContent>
</IonPage>
);
};

View File

@@ -0,0 +1,207 @@
/**
* @file Nearby page
*/
/* eslint-disable unicorn/no-null */
/* eslint-disable camelcase */
import {
IonButtons,
IonFab,
IonFabButton,
IonHeader,
IonIcon,
IonItem,
IonItemOption,
IonMenuButton,
IonPage,
IonTitle,
IonToolbar,
} from "@ionic/react";
import {
addOutline,
addSharp,
arrowDownOutline,
arrowDownSharp,
arrowUpOutline,
arrowUpSharp,
} from "ionicons/icons";
import {FC, useState} from "react";
import {useHistory} from "react-router-dom";
import {useMeasure} from "react-use";
import {PostCard} from "~/components/post-card";
import {ScrollableContent} from "~/components/scrollable-content";
import {SwipeableItem} from "~/components/swipeable-item";
import {insertView, toggleVote} from "~/lib/entities";
import {useEphemeralStore} from "~/lib/stores/ephemeral";
import {usePersistentStore} from "~/lib/stores/persistent";
import {client} from "~/lib/supabase";
import {Post} from "~/lib/types";
/**
* Nearby page
* @returns JSX
*/
export const Nearby: FC = () => {
// Hooks
const [posts, setPosts] = useState<Post[]>([]);
const waitForLocation = useEphemeralStore(state => state.waitForLocation);
const showFABs = usePersistentStore(state => state.showFABs);
const [sizerRef, {width}] = useMeasure<HTMLDivElement>();
const history = useHistory();
// Methods
/**
* Set the post
* @param newPost New post
* @returns Void
*/
const setPost = (newPost: Post) =>
setPosts(posts.map(post => (post.id === newPost.id ? newPost : post)));
/**
* Fetch posts
* @param limit Posts limit
* @param cutoffRank Cutoff rank or undefined for no cutoff
* @returns Posts
*/
const fetchPosts = async (limit: number, cutoffRank?: number) => {
// Wait for a location
await waitForLocation();
// Build the query
let query = client
.from("personalized_posts")
.select(
"id, poster_id, created_at, content, has_media, blur_hash, aspect_ratio, views, upvotes, downvotes, comments, distance, rank, is_mine, poster_color, poster_emoji, upvote",
);
if (cutoffRank !== undefined) {
query = query.lt("rank", cutoffRank);
}
query = query.order("rank", {ascending: false}).limit(limit);
// Fetch posts
const {data, error} = await query;
// Handle error
if (data === null || error !== null) {
return [];
}
return data as Post[];
};
/**
* Post view event handler
* @param post Post that was viewed
*/
const onPostViewed = async (post: Post) => {
// Insert the view
await insertView("post_views", "post_id", post.id);
};
/**
* Toggle a vote on a post
* @param post Post to toggle the vote on
* @param upvote Whether the vote is an upvote or a downvote
*/
const togglePostVote = async (post: Post, upvote: boolean) => {
await toggleVote(post, setPost, upvote, "post_votes", "post_id");
};
/**
* Delete a post
* @param post Post to delete
*/
const deletePost = async (post: Post) => {
// Delete the post
await client.from("posts").delete().eq("id", post.id);
// Remove the post from the state
setPosts(posts.filter(p => p.id !== post.id));
};
/**
* Create a post
*/
const createPost = () => {
// Go to the create post page
history.push("/posts/create/1");
};
return (
<IonPage>
<IonHeader className="ion-no-border" translucent={true}>
<IonToolbar>
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Nearby</IonTitle>
</IonToolbar>
</IonHeader>
<ScrollableContent
contentItemName="post"
contentItems={posts}
setContentItems={setPosts}
contentItemIDKey="id"
contentItemRankKey="rank"
onContentItemViewed={onPostViewed}
contentItemRenderer={(post, index, onLoad) => (
<SwipeableItem
key={post.id}
startOption={
<IonItemOption color="success">
<IonIcon
slot="icon-only"
ios={arrowUpOutline}
md={arrowUpSharp}
/>
</IonItemOption>
}
endOption={
<IonItemOption color="danger">
<IonIcon
slot="icon-only"
ios={arrowDownOutline}
md={arrowDownSharp}
/>
</IonItemOption>
}
startAction={() => togglePostVote(post, true)}
endAction={() => togglePostVote(post, false)}
>
<IonItem lines="none">
<PostCard
className={`max-w-256 mb-2 mx-auto w-full ${index === 0 ? "mt-4" : "mt-2"}`}
postLinkDetail={true}
width={width}
post={post}
onLoad={onLoad}
toggleVote={upvote => togglePostVote(post, upvote)}
onDeleted={post.is_mine ? () => deletePost(post) : undefined}
/>
</IonItem>
</SwipeableItem>
)}
fetchContent={fetchPosts}
header={
<IonItem className="h-0" lines="none">
<div className="max-w-256 w-full" ref={sizerRef} />
</IonItem>
}
/>
{showFABs && (
<IonFab slot="fixed" horizontal="end" vertical="bottom">
<IonFabButton onClick={createPost}>
<IonIcon ios={addOutline} md={addSharp} />
</IonFabButton>
</IonFab>
)}
</IonPage>
);
};

View File

@@ -0,0 +1,11 @@
.textarea :global(.textarea-wrapper-inner) {
@apply h-full;
}
.textarea :global(.native-textarea) {
@apply !pt-0;
}
.collapsedItem:global(::part(native)) {
@apply min-h-unset;
}

View File

@@ -0,0 +1,255 @@
/* eslint-disable camelcase */
/**
* @file Create comment step 1 page
*/
import {zodResolver} from "@hookform/resolvers/zod";
import {
IonButton,
IonIcon,
IonItem,
IonLabel,
IonList,
IonNote,
IonSegment,
IonSegmentButton,
IonTextarea,
IonToggle,
} from "@ionic/react";
import {
codeSlashOutline,
codeSlashSharp,
createOutline,
createSharp,
eyeOutline,
eyeSharp,
} from "ionicons/icons";
import {FC, useEffect, useState} from "react";
import {Controller, useForm} from "react-hook-form";
import {useHistory, useParams} from "react-router-dom";
import {z} from "zod";
import {CreateCommentContainer} from "~/components/create-comment-container";
import {Markdown} from "~/components/markdown";
import {SupplementalError} from "~/components/supplemental-error";
import {useEphemeralStore} from "~/lib/stores/ephemeral";
import {client} from "~/lib/supabase";
import {GlobalMessageMetadata} from "~/lib/types";
import styles from "~/pages/posts/[id]/comments/create/step1.module.css";
/**
* Comment created message metadata
*/
const COMMENT_CREATED_MESSAGE_METADATA: GlobalMessageMetadata = {
symbol: Symbol("comment.created"),
name: "Success",
description: "Your comment has been created.",
};
/**
* Content mode
*/
enum ContentMode {
/**
* View the raw content
*/
RAW = "raw",
/**
* Preview the rendered content
*/
PREVIEW = "preview",
}
/**
* Minimum content length
*/
const MIN_CONTENT_LENGTH = 1;
/**
* Maximum content length
*/
const MAX_CONTENT_LENGTH = 300;
/**
* Form schema
*/
const formSchema = z.object({
anonymous: z.boolean(),
content: z.string().min(MIN_CONTENT_LENGTH).max(MAX_CONTENT_LENGTH),
});
/**
* Form schema type
*/
type FormSchema = z.infer<typeof formSchema>;
/**
* Create comment step 1 page
* @returns JSX
*/
export const Step1: FC = () => {
// Hooks
const [contentTextarea, setContentTextarea] =
// eslint-disable-next-line unicorn/no-null
useState<HTMLIonTextareaElement | null>(null);
const setMessage = useEphemeralStore(state => state.setMessage);
const refreshContent = useEphemeralStore(state => state.refreshContent);
const [contentMode, setContentMode] = useState<ContentMode>(ContentMode.RAW);
const params = useParams<{id: string}>();
const history = useHistory();
const {control, handleSubmit, reset} = useForm<FormSchema>({
defaultValues: {
anonymous: false,
},
resolver: zodResolver(formSchema),
});
// Effects
useEffect(() => {
if (contentTextarea === null) {
return;
}
// Focus the content textarea
if (contentMode === ContentMode.RAW) {
// setFocus has a race condition
setTimeout(() => contentTextarea.setFocus(), 50);
}
}, [contentMode, contentTextarea]);
// Methods
/**
* Form submit handler
* @param form Form data
*/
const onSubmit = async (form: FormSchema) => {
// Insert the post
const {error} = await client.from("comments").insert({
post_id: params.id,
private_anonymous: form.anonymous,
content: form.content,
});
// Handle error
if (error !== null) {
return;
}
// Reset the post forms
reset();
// Display the message
setMessage(COMMENT_CREATED_MESSAGE_METADATA);
// Refetch the content
await refreshContent?.();
// Go back
history.goBack();
};
return (
<CreateCommentContainer postID={params.id}>
<form className="h-full" onSubmit={handleSubmit(onSubmit)}>
<IonList className="flex flex-col h-full py-0">
<Controller
control={control}
name="content"
render={({
field: {onChange, onBlur, value},
fieldState: {error},
}) => (
<div className="flex flex-col flex-1 px-4 pt-4">
<IonLabel className="pb-2">Content</IonLabel>
<div className="flex-1 relative">
<div className="absolute flex flex-col left-0 right-0 bottom-0 top-0">
{contentMode === ContentMode.RAW ? (
<IonTextarea
className={`h-full w-full ${styles.textarea}`}
autocapitalize="on"
counter={true}
fill="outline"
maxlength={MAX_CONTENT_LENGTH}
minlength={MIN_CONTENT_LENGTH}
onIonBlur={onBlur}
onIonInput={onChange}
ref={setContentTextarea}
spellcheck={true}
value={value}
/>
) : (
<>
<Markdown
className="break-anywhere h-full overflow-auto py-2 text-wrap w-full"
raw={value}
/>
</>
)}
</div>
</div>
<SupplementalError error={error?.message} />
<IonSegment
className="mt-7"
value={contentMode}
onIonChange={event =>
setContentMode(event.detail.value as ContentMode)
}
>
<IonSegmentButton layout="icon-start" value={ContentMode.RAW}>
<IonLabel>Raw</IonLabel>
<IonIcon ios={codeSlashOutline} md={codeSlashSharp} />
</IonSegmentButton>
<IonSegmentButton
layout="icon-start"
value={ContentMode.PREVIEW}
>
<IonLabel>Preview</IonLabel>
<IonIcon ios={eyeOutline} md={eyeSharp} />
</IonSegmentButton>
</IonSegment>
</div>
)}
/>
<IonItem>
<Controller
control={control}
name="anonymous"
render={({field: {onChange, onBlur, value}}) => (
<IonToggle
checked={value}
onIonBlur={onBlur}
onIonChange={event => onChange(event.detail.checked)}
>
<IonLabel>Make this post anonymous</IonLabel>
<IonNote className="whitespace-break-spaces">
Your username will be hidden from other users.
</IonNote>
</IonToggle>
)}
/>
</IonItem>
<div className="m-4">
<IonButton
className="m-0 overflow-hidden rounded-lg w-full"
expand="full"
type="submit"
>
Post
<IonIcon slot="end" ios={createOutline} md={createSharp} />
</IonButton>
</div>
</IonList>
</form>
</CreateCommentContainer>
);
};

View File

@@ -0,0 +1,313 @@
/**
* @file Post index page
*/
/* eslint-disable unicorn/no-null */
/* eslint-disable camelcase */
import {
IonBackButton,
IonButtons,
IonFab,
IonFabButton,
IonHeader,
IonIcon,
IonItem,
IonItemOption,
IonPage,
IonTitle,
IonToolbar,
} from "@ionic/react";
import {
addOutline,
addSharp,
arrowDownOutline,
arrowDownSharp,
arrowUpOutline,
arrowUpSharp,
} from "ionicons/icons";
import {FC, useEffect, useState} from "react";
import {useHistory, useParams} from "react-router-dom";
import {useMeasure} from "react-use";
import {CommentCard} from "~/components/comment-card";
import {PostCard} from "~/components/post-card";
import {ScrollableContent} from "~/components/scrollable-content";
import {SwipeableItem} from "~/components/swipeable-item";
import {insertView, toggleVote} from "~/lib/entities";
import {useEphemeralStore} from "~/lib/stores/ephemeral";
import {usePersistentStore} from "~/lib/stores/persistent";
import {client} from "~/lib/supabase";
import {Comment, Post} from "~/lib/types";
/**
* Post index page
* @returns JSX
*/
export const PostIndex: FC = () => {
// Hooks
const [post, setPost] = useState<Post | undefined>();
const [comments, setComments] = useState<Comment[]>([]);
const waitForLocation = useEphemeralStore(state => state.waitForLocation);
const showFABs = usePersistentStore(state => state.showFABs);
const [sizerRef, {width}] = useMeasure<HTMLDivElement>();
const params = useParams<{id: string}>();
const history = useHistory();
// Effects
useEffect(() => {
// Update the initial post
updatePost();
}, []);
// Methods
/**
* Set the comment
* @param newComment New comment
* @returns Void
*/
const setComment = (newComment: Comment) =>
setComments(
comments.map(comment =>
comment.id === newComment.id ? newComment : comment,
),
);
/**
* Update the post
*/
const updatePost = async () => {
// Get the post
const {data, error} = await client
.from("personalized_posts")
.select(
"id, poster_id, created_at, content, has_media, blur_hash, aspect_ratio, views, upvotes, downvotes, comments, distance, rank, is_mine, poster_color, poster_emoji, upvote",
)
.eq("id", params.id)
.single();
// Handle error
if (data === null || error !== null) {
return;
}
// Update the state
setPost(data as any);
};
/**
* Fetch comments
* @param limit Comments limit
* @param cutoffRank Cutoff rank or undefined for no cutoff
* @returns Comments
*/
const fetchComments = async (limit: number, cutoffRank?: number) => {
// Wait for a location
await waitForLocation();
// Build the query
let query = client
.from("personalized_comments")
.select(
"id, commenter_id, post_id, parent_id, created_at, content, views, upvotes, downvotes, rank, is_mine, commenter_color, commenter_emoji, upvote",
)
.eq("post_id", params.id);
if (cutoffRank !== undefined) {
query = query.lt("rank", cutoffRank);
}
query = query.order("rank", {ascending: false}).limit(limit);
// Fetch comments
const {data, error} = await query;
// Handle error
if (data === null || error !== null) {
return [];
}
return data as any as Comment[];
};
/**
* Comment view event handler
* @param comment Comment that was viewed
*/
const onCommentViewed = async (comment: Comment) => {
// Insert the view
await insertView("comment_views", "comment_id", comment.id);
};
/**
* Toggle a vote on a post
* @param post Post to toggle the vote on
* @param upvote Whether the vote is an upvote or a downvote
*/
const togglePostVote = async (post: Post, upvote: boolean) => {
await toggleVote(post, setPost, upvote, "post_votes", "post_id");
};
/**
* Delete a post
* @param post Post to delete
*/
const deletePost = async (post: Post) => {
// Delete the post
await client.from("posts").delete().eq("id", post.id);
// Redirect to the nearby page
history.push("/nearby");
};
/**
* Toggle a vote on a comment
* @param comment Comment to toggle the vote on
* @param upvote Whether the vote is an upvote or a downvote
*/
const toggleCommentVote = async (comment: Comment, upvote: boolean) => {
await toggleVote(
comment,
setComment,
upvote,
"comment_votes",
"comment_id",
);
};
/**
* Delete a comment
* @param comment Comment to delete
*/
const deleteComments = async (comment: Comment) => {
// Delete the comment
await client.from("comments").delete().eq("id", comment.id);
// Remove the comment from the state
setComments(comments.filter(c => c.id !== comment.id));
};
/**
* Create a comment
*/
const createComment = () => {
// Go to the create comment page
history.push(`/posts/${params.id}/comments/create/1`);
};
return (
<IonPage>
<IonHeader className="ion-no-border" translucent={true}>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/nearby" />
</IonButtons>
<IonTitle>Post</IonTitle>
</IonToolbar>
</IonHeader>
<ScrollableContent
contentItemName="comment"
contentItems={comments}
setContentItems={setComments}
contentItemIDKey="id"
contentItemRankKey="rank"
onContentItemViewed={onCommentViewed}
contentItemRenderer={(comment, _, onLoad) => (
<SwipeableItem
key={comment.id}
startOption={
<IonItemOption color="success">
<IonIcon
slot="icon-only"
ios={arrowUpOutline}
md={arrowUpSharp}
/>
</IonItemOption>
}
endOption={
<IonItemOption color="danger">
<IonIcon
slot="icon-only"
ios={arrowDownOutline}
md={arrowDownSharp}
/>
</IonItemOption>
}
startAction={() => toggleCommentVote(comment, true)}
endAction={() => toggleCommentVote(comment, false)}
>
<IonItem lines="none">
<CommentCard
className="max-w-256 mx-auto my-2 w-full"
comment={comment}
onLoad={onLoad}
toggleVote={upvote => toggleCommentVote(comment, upvote)}
onDeleted={
comment.is_mine ? () => deleteComments(comment) : undefined
}
/>
</IonItem>
</SwipeableItem>
)}
fetchContent={fetchComments}
onRefresh={updatePost}
header={
<>
{post !== undefined && (
<SwipeableItem
className="overflow-initial"
key={post.id}
startOption={
<IonItemOption color="success">
<IonIcon
slot="icon-only"
ios={arrowUpOutline}
md={arrowUpSharp}
/>
</IonItemOption>
}
endOption={
<IonItemOption color="danger">
<IonIcon
slot="icon-only"
ios={arrowDownOutline}
md={arrowDownSharp}
/>
</IonItemOption>
}
startAction={() => togglePostVote(post, true)}
endAction={() => togglePostVote(post, false)}
>
<IonItem>
<PostCard
className="max-w-256 mb-2 mt-4 mx-auto w-full"
post={post}
postLinkDetail={false}
width={width}
toggleVote={upvote => togglePostVote(post, upvote)}
onDeleted={
post.is_mine ? () => deletePost(post) : undefined
}
/>
</IonItem>
</SwipeableItem>
)}
<IonItem className="h-0" lines="none">
<div className="max-w-256 w-full" ref={sizerRef} />
</IonItem>
</>
}
/>
{showFABs && (
<IonFab slot="fixed" horizontal="end" vertical="bottom">
<IonFabButton onClick={createComment}>
<IonIcon ios={addOutline} md={addSharp} />
</IonFabButton>
</IonFab>
)}
</IonPage>
);
};

View File

@@ -0,0 +1,11 @@
.textarea :global(.textarea-wrapper-inner) {
@apply h-full;
}
.textarea :global(.native-textarea) {
@apply !pt-0;
}
.collapsedItem:global(::part(native)) {
@apply min-h-unset;
}

View File

@@ -0,0 +1,529 @@
/**
* @file Create post step 1 page
*/
import {zodResolver} from "@hookform/resolvers/zod";
import {
IonButton,
IonIcon,
IonItem,
IonLabel,
IonList,
IonSegment,
IonSegmentButton,
IonTextarea,
useIonActionSheet,
useIonLoading,
} from "@ionic/react";
import {
arrowForwardOutline,
arrowForwardSharp,
closeOutline,
closeSharp,
codeSlashOutline,
codeSlashSharp,
eyeOutline,
eyeSharp,
imageOutline,
imageSharp,
} from "ionicons/icons";
import {FC, useEffect, useRef, useState} from "react";
import {Controller, useForm} from "react-hook-form";
import {useHistory} from "react-router-dom";
import {z} from "zod";
import {CreatePostContainer} from "~/components/create-post-container";
import {Markdown} from "~/components/markdown";
import {SupplementalError} from "~/components/supplemental-error";
import {
BLURHASH_COMPONENT_X,
BLURHASH_COMPONENT_Y,
captureMedia,
createBlurhash,
createMediaCanvas,
createMediaElement,
exportMedia,
getCategory,
getMediaDimensions,
MAX_MEDIA_DIMENSION,
MAX_MEDIA_SIZE,
MIN_MEDIA_DIMENSION,
PREFERRED_IMAGE_MIME_TYPE,
PREFERRED_IMAGE_QUALITY,
scaleCanvas,
} from "~/lib/media";
import {useEphemeralStore} from "~/lib/stores/ephemeral";
import {MediaCategory, MediaDimensions} from "~/lib/types";
import styles from "~/pages/posts/create/step1.module.css";
/**
* Content mode
*/
enum ContentMode {
/**
* View the raw content
*/
RAW = "raw",
/**
* Preview the rendered content
*/
PREVIEW = "preview",
}
/**
* Minimum content length
*/
const MIN_CONTENT_LENGTH = 1;
/**
* Maximum content length
*/
const MAX_CONTENT_LENGTH = 300;
/**
* Form schema
*/
const formSchema = z.object({
content: z.string().min(MIN_CONTENT_LENGTH).max(MAX_CONTENT_LENGTH),
media: z
.object({
aspectRatio: z.number(),
blurHash: z.string(),
blob: z.instanceof(Blob),
category: z.nativeEnum(MediaCategory),
objectURL: z.string(),
})
.optional(),
});
/**
* Form schema input type
*/
type FormSchemaInput = z.input<typeof formSchema>;
/**
* Form schema output type
*/
type FormSchemaOutput = z.output<typeof formSchema>;
/**
* Create post step 1 page
* @returns JSX
*/
export const Step1: FC = () => {
// Hooks
const [contentTextarea, setContentTextarea] =
// eslint-disable-next-line unicorn/no-null
useState<HTMLIonTextareaElement | null>(null);
const [contentMode, setContentMode] = useState<ContentMode>(ContentMode.RAW);
const mediaInput = useRef<HTMLInputElement | null>(null);
const post = useEphemeralStore(state => state.postBeingCreated);
const setPost = useEphemeralStore(state => state.setPostBeingCreated);
const [presentActionSheet] = useIonActionSheet();
const [presentLoading, dismissLoading] = useIonLoading();
const history = useHistory();
const {control, handleSubmit, reset, setError, setValue, watch} = useForm<
FormSchemaInput,
z.ZodTypeDef,
FormSchemaOutput
>({
resolver: zodResolver(formSchema),
});
// Variables
const media = watch("media");
// Effects
useEffect(() => {
if (contentTextarea === null) {
return;
}
// Focus the content textarea
if (contentMode === ContentMode.RAW) {
// setFocus has a race condition
setTimeout(() => contentTextarea.setFocus(), 50);
}
}, [contentMode, contentTextarea]);
useEffect(() => {
// Reset the form
if (post === undefined) {
reset();
if (mediaInput.current !== null) {
mediaInput.current.value = "";
}
}
}, [post]);
useEffect(() => {
// Update the upload value
if (media === undefined && mediaInput.current !== null) {
mediaInput.current.value = "";
}
}, [media]);
// Methods
/**
* Capture media and update the form
* @param newCapture Whether to capture new media
* @param rawCategory Media category
*/
const captureMediaAndUpdateForm = async <T extends boolean>(
newCapture: T,
rawCategory: T extends true ? MediaCategory : MediaCategory | undefined,
) => {
// Capture the media
const media = await captureMedia(newCapture, rawCategory);
// Start the loading indicator
await presentLoading({
message: "Processing media...",
});
// Get the media category
const category = rawCategory ?? getCategory(media.type);
if (category === undefined) {
setError("media", {message: `Unsupported media type ${media.type}`});
await dismissLoading();
return;
}
// Generate an object URL for the media
const originalObjectURL = URL.createObjectURL(media);
// Create the media element and canvas
const element = await createMediaElement(category, originalObjectURL);
const dimensions = getMediaDimensions(category, element);
const aspectRatio = dimensions.width / dimensions.height;
let canvas = createMediaCanvas(element, dimensions);
// Check the media dimensions
if (
dimensions.height > MAX_MEDIA_DIMENSION ||
dimensions.width > MAX_MEDIA_DIMENSION
) {
switch (category) {
case MediaCategory.IMAGE: {
// Calculate scaled dimensions (while preserving aspect ratio)
const scaledDimensions: MediaDimensions =
aspectRatio > 1
? {
height: Math.floor(MAX_MEDIA_DIMENSION / aspectRatio),
width: MAX_MEDIA_DIMENSION,
}
: {
height: MAX_MEDIA_DIMENSION,
width: Math.floor(MAX_MEDIA_DIMENSION * aspectRatio),
};
// Scale the media
canvas = scaleCanvas(canvas, scaledDimensions);
break;
}
default:
setError("media", {
message: `Media must be at most ${MAX_MEDIA_DIMENSION} x ${MAX_MEDIA_DIMENSION}`,
});
await dismissLoading();
return;
}
}
if (
dimensions.height < MIN_MEDIA_DIMENSION ||
dimensions.width < MIN_MEDIA_DIMENSION
) {
setError("media", {
message: `Media must be at least ${MIN_MEDIA_DIMENSION} x ${MIN_MEDIA_DIMENSION}`,
});
await dismissLoading();
return;
}
// Generate the blurhash
const blurHash = await createBlurhash(
canvas,
BLURHASH_COMPONENT_X,
BLURHASH_COMPONENT_Y,
);
// Export the media if it is an image (to strip metadata)
let blob: Blob;
let objectURL: string;
switch (category) {
case MediaCategory.IMAGE:
blob = await exportMedia(
canvas,
PREFERRED_IMAGE_MIME_TYPE,
PREFERRED_IMAGE_QUALITY,
);
objectURL = URL.createObjectURL(blob);
break;
default:
blob = media;
objectURL = originalObjectURL;
break;
}
// Check the media size
if (blob.size > MAX_MEDIA_SIZE) {
setError("media", {
message: `Media must be at most ${MAX_MEDIA_SIZE / (1024 * 1024)} MiB`,
});
await dismissLoading();
return;
}
setValue("media", {
aspectRatio,
blurHash,
blob,
category,
objectURL,
});
await dismissLoading();
};
/**
* Prompt the user to add media to the post
* @returns Promise
*/
const addMedia = () =>
presentActionSheet({
header: "Choose Photo/Video",
subHeader: "Note: you can only add one photo or video per post.",
buttons: [
{
text: "Cancel",
role: "cancel",
},
{
text: "New photo",
role: "selected",
/**
* Capture a new photo
*/
handler: () => {
captureMediaAndUpdateForm(true, MediaCategory.IMAGE);
},
},
{
text: "New video",
role: "selected",
/**
* Capture a new video
*/
handler: () => {
captureMediaAndUpdateForm(true, MediaCategory.VIDEO);
},
},
{
text: "Existing photo/video",
role: "selected",
/**
* Capture an existing photo or video
*/
handler: () => {
captureMediaAndUpdateForm(false, undefined);
},
},
],
});
/**
* Form submit handler
* @param form Form data
*/
const onSubmit = async (form: FormSchemaOutput) => {
// Update the post
setPost({
content: form.content,
media: form.media,
});
// Go to the next step
history.push("/posts/create/2");
};
return (
<CreatePostContainer>
<form className="h-full" onSubmit={handleSubmit(onSubmit)}>
<IonList className="flex flex-col h-full py-0">
<Controller
control={control}
name="content"
render={({
field: {onChange, onBlur, value},
fieldState: {error},
}) => (
<div className="flex flex-col flex-1 px-4 pt-4">
<IonLabel className="pb-2">Content</IonLabel>
<div className="flex-1 relative">
<div className="absolute flex flex-col left-0 right-0 bottom-0 top-0">
{contentMode === ContentMode.RAW ? (
<IonTextarea
className={`h-full w-full ${styles.textarea}`}
autocapitalize="on"
counter={true}
fill="outline"
maxlength={MAX_CONTENT_LENGTH}
minlength={MIN_CONTENT_LENGTH}
onIonBlur={onBlur}
onIonInput={onChange}
ref={setContentTextarea}
spellcheck={true}
value={value}
/>
) : (
<>
<Markdown
className="break-anywhere h-full overflow-auto py-2 text-wrap w-full"
raw={value}
/>
</>
)}
</div>
</div>
<SupplementalError error={error?.message} />
<IonSegment
className="mt-7"
value={contentMode}
onIonChange={event =>
setContentMode(event.detail.value as ContentMode)
}
>
<IonSegmentButton layout="icon-start" value={ContentMode.RAW}>
<IonLabel>Raw</IonLabel>
<IonIcon ios={codeSlashOutline} md={codeSlashSharp} />
</IonSegmentButton>
<IonSegmentButton
layout="icon-start"
value={ContentMode.PREVIEW}
>
<IonLabel>Preview</IonLabel>
<IonIcon ios={eyeOutline} md={eyeSharp} />
</IonSegmentButton>
</IonSegment>
</div>
)}
/>
<IonItem className={`mt-4 ${styles.collapsedItem}`} />
<IonItem>
<Controller
control={control}
name="media"
render={({fieldState: {error}}) => (
<div className="flex flex-col w-full">
<IonButton className="w-full" fill="clear" onClick={addMedia}>
<div className="flex flex-col">
<div className="flex flex-row items-center justify-center relative w-full my-2">
<IonIcon
className="text-2xl"
ios={imageOutline}
md={imageSharp}
/>
<p className="ml-2 text-3.5 text-center">
Add a photo or video
</p>
{media !== undefined && (
<IonButton
fill="clear"
onClick={event => {
event.stopPropagation();
setValue("media", undefined);
}}
>
<IonIcon
slot="icon-only"
ios={closeOutline}
md={closeSharp}
/>
</IonButton>
)}
</div>
{media !== undefined && (
<div className="h-[50vh] mb-4 overflow-hidden pointer-events-none rounded-lg w-full">
{(() => {
switch (media?.category) {
case MediaCategory.IMAGE:
return (
<img
alt="Media preview"
className="h-full w-full"
src={media.objectURL}
/>
);
case MediaCategory.VIDEO:
return (
<video
autoPlay
className="h-full w-full"
loop
muted
src={media.objectURL}
/>
);
}
})()}
</div>
)}
</div>
</IonButton>
<SupplementalError error={error?.message} />
</div>
)}
/>
</IonItem>
<div className="m-4">
<IonButton
className="m-0 overflow-hidden rounded-lg w-full"
expand="full"
type="submit"
>
Next
<IonIcon
slot="end"
ios={arrowForwardOutline}
md={arrowForwardSharp}
/>
</IonButton>
</div>
</IonList>
</form>
</CreatePostContainer>
);
};

View File

@@ -0,0 +1,3 @@
.range {
--knob-size: 1.5rem;
}

View File

@@ -0,0 +1,345 @@
/* eslint-disable unicorn/no-null */
/* eslint-disable camelcase */
/**
* @file Create post step 2 page
*/
import {zodResolver} from "@hookform/resolvers/zod";
import {
IonButton,
IonIcon,
IonInput,
IonItem,
IonLabel,
IonList,
IonNote,
IonRange,
IonToggle,
} from "@ionic/react";
import {
createOutline,
createSharp,
globeOutline,
globeSharp,
locationOutline,
locationSharp,
} from "ionicons/icons";
import {round} from "lodash-es";
import {FC, useEffect} from "react";
import {Controller, useForm} from "react-hook-form";
import {useHistory} from "react-router-dom";
import {z} from "zod";
import {CreatePostContainer} from "~/components/create-post-container";
import {Map} from "~/components/map";
import {SupplementalError} from "~/components/supplemental-error";
import {useEphemeralStore} from "~/lib/stores/ephemeral";
import {usePersistentStore} from "~/lib/stores/persistent";
import {client} from "~/lib/supabase";
import {GlobalMessageMetadata, MeasurementSystem} from "~/lib/types";
import {METERS_TO_KILOMETERS, METERS_TO_MILES} from "~/lib/utils";
import styles from "~/pages/posts/create/step2.module.css";
/**
* Geolocation not supported message metadata
*/
const GEOLOCATION_NOT_SUPPORTED_MESSAGE_METADATA: GlobalMessageMetadata = {
symbol: Symbol("geolocation.not-supported"),
name: "Geolocation not supported",
description: "Geolocation is not supported on this device.",
};
/**
* Post created message metadata
*/
const POST_CREATED_MESSAGE_METADATA: GlobalMessageMetadata = {
symbol: Symbol("post.created"),
name: "Success",
description: "Your post has been created.",
};
/**
* Minimum radius (In meters)
*/
const MIN_RADIUS = 500;
/**
* Maximum radius (In meters)
*/
const MAX_RADIUS = 50000;
/**
* Form schema
*/
const formSchema = z.object({
anonymous: z.boolean(),
radius: z
.number()
.min(MIN_RADIUS - 1e-4)
.max(MAX_RADIUS + 1e-4),
});
// Types
/**
* Form schema type
*/
type FormSchema = z.infer<typeof formSchema>;
/**
* Create post step 2 page
* @returns JSX
*/
export const Step2: FC = () => {
// Hooks
const location = useEphemeralStore(state => state.location);
const setMessage = useEphemeralStore(state => state.setMessage);
const refreshContent = useEphemeralStore(state => state.refreshContent);
const measurementSystem = usePersistentStore(
state => state.measurementSystem,
);
const post = useEphemeralStore(state => state.postBeingCreated);
const setPost = useEphemeralStore(state => state.setPostBeingCreated);
const history = useHistory();
// Variables
/**
* Conversion factor
*/
const conversionFactor =
measurementSystem === MeasurementSystem.METRIC
? METERS_TO_KILOMETERS
: METERS_TO_MILES;
let minRadius: number;
let maxRadius: number;
let defaultRadius: number;
let radiusStep: number;
switch (measurementSystem) {
case MeasurementSystem.METRIC:
minRadius = 1;
maxRadius = 50;
defaultRadius = 5 / conversionFactor;
radiusStep = 1;
break;
case MeasurementSystem.IMPERIAL:
minRadius = 0.5;
maxRadius = 30;
defaultRadius = 3 / conversionFactor;
radiusStep = 0.5;
break;
}
// More hooks
const {control, handleSubmit, watch, reset} = useForm<FormSchema>({
defaultValues: {
anonymous: false,
radius: defaultRadius,
},
resolver: zodResolver(formSchema),
});
const radius = watch("radius");
// Effects
useEffect(() => {
// Reset the form
if (post === undefined) {
reset();
}
}, [post]);
// Methods
/**
* Form submit handler
* @param form Form data
*/
const onSubmit = async (form: FormSchema) => {
if (post === undefined) {
throw new TypeError("Post is undefined");
}
if (location === undefined) {
setMessage(GEOLOCATION_NOT_SUPPORTED_MESSAGE_METADATA);
}
// Insert the post
const {data, error} = await client
.from("posts")
.insert({
private_anonymous: form.anonymous,
radius: form.radius,
content: post.content!,
has_media: post.media !== undefined,
blur_hash: post.media?.blurHash,
aspect_ratio: post.media?.aspectRatio,
})
.select("id")
.single<{
id: string;
}>();
// Handle error
if (data === null || error !== null) {
return;
}
// Upload the media
if (post.media?.blob !== undefined) {
const {error} = await client.storage
.from("media")
.upload(`posts/${data.id}`, post.media.blob);
// Handle error
if (error !== null) {
return;
}
}
// Reset the post forms
setPost(undefined);
// Display the message
setMessage(POST_CREATED_MESSAGE_METADATA);
// Refetch the content
await refreshContent?.();
// Go back twice
history.goBack();
history.goBack();
};
return (
<CreatePostContainer>
<form className="h-full" onSubmit={handleSubmit(onSubmit)}>
<IonList className="flex flex-col h-full py-0">
<IonItem>
<Controller
control={control}
name="anonymous"
render={({field: {onChange, onBlur, value}}) => (
<IonToggle
checked={value}
onIonBlur={onBlur}
onIonChange={event => onChange(event.detail.checked)}
>
<IonLabel>Make this post anonymous</IonLabel>
<IonNote className="whitespace-break-spaces">
Your username will be hidden from other users.
</IonNote>
</IonToggle>
)}
/>
</IonItem>
<div className="flex flex-1 flex-col mt-4 mx-4">
<IonLabel>Radius</IonLabel>
<IonNote className="whitespace-break-spaces">
Only people in the blue region will be able to see and comment on
this post.
</IonNote>
<Controller
control={control}
name="radius"
render={({
field: {onChange, onBlur, value},
fieldState: {error},
}) => (
<>
<div className="flex flex-row items-center justify-center">
<IonRange
aria-label="Radius"
className={`flex-1 ml-2 mr-2 ${styles.range}`}
min={minRadius}
max={maxRadius}
step={radiusStep}
onIonBlur={onBlur}
onIonInput={event =>
onChange(
(event.detail.value as number) / conversionFactor,
)
}
value={value * conversionFactor}
>
<IonIcon
slot="start"
ios={locationOutline}
md={locationSharp}
/>
<IonIcon slot="end" ios={globeOutline} md={globeSharp} />
</IonRange>
<IonInput
aria-label="Radius"
className="ml-2 w-28"
fill="outline"
onIonBlur={onBlur}
onIonChange={event =>
onChange(
Number.parseInt(event.detail.value ?? "0") /
conversionFactor,
)
}
type="number"
min={minRadius}
max={maxRadius}
step={radiusStep.toString()}
value={round(value * conversionFactor, 1)}
>
<IonLabel class="!ml-2" slot="end">
{measurementSystem === MeasurementSystem.METRIC
? "km"
: "mi"}
</IonLabel>
</IonInput>
</div>
<SupplementalError error={error?.message} />
</>
)}
/>
{location !== undefined && (
<Map
className="flex-1 mt-4 overflow-hidden rounded-lg w-full"
position={[location.coords.latitude, location.coords.longitude]}
bounds={[
[
location.coords.latitude + 0.75,
location.coords.longitude + 0.75,
],
[
location.coords.latitude - 0.75,
location.coords.longitude - 0.75,
],
]}
zoom={11}
minZoom={6}
circle={{
center: [location.coords.latitude, location.coords.longitude],
radius: radius,
}}
/>
)}
</div>
<div className="m-4">
<IonButton
className="m-0 overflow-hidden rounded-lg w-full"
expand="full"
type="submit"
>
Post
<IonIcon slot="end" ios={createOutline} md={createSharp} />
</IonButton>
</div>
</IonList>
</form>
</CreatePostContainer>
);
};

View File

@@ -0,0 +1,3 @@
.modal {
--height: auto;
}

View File

@@ -0,0 +1,511 @@
/**
* @file Setting page
*/
import {
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonItem,
IonItemDivider,
IonItemGroup,
IonLabel,
IonList,
IonMenuButton,
IonModal,
IonNote,
IonPage,
IonSelect,
IonSelectOption,
IonTitle,
IonToggle,
IonToolbar,
isPlatform,
useIonActionSheet,
} from "@ionic/react";
import {
downloadOutline,
downloadSharp,
ellipsisVertical,
logOutOutline,
logOutSharp,
menuSharp,
refreshOutline,
refreshSharp,
warningOutline,
warningSharp,
} from "ionicons/icons";
import {FC, useEffect, useRef, useState} from "react";
import {useHistory} from "react-router-dom";
import AddToHomeScreen from "~/assets/icons/md-add-to-home-screen.svg?react";
import PlusApp from "~/assets/icons/sf-symbols-plus.app.svg?react";
import SquareAndArrowUp from "~/assets/icons/sf-symbols-square.and.arrow.up.svg?react";
import {useEphemeralStore} from "~/lib/stores/ephemeral";
import {usePersistentStore} from "~/lib/stores/persistent";
import {client} from "~/lib/supabase";
import {GlobalMessageMetadata, MeasurementSystem, Theme} from "~/lib/types";
import {GIT_BRANCH, GIT_COMMIT, VERSION} from "~/lib/vars";
import styles from "~/pages/settings.module.css";
/**
* Account deleted message metadata
*/
const ACCOUNT_DELETED_MESSAGE_METADATA: GlobalMessageMetadata = {
symbol: Symbol("settings.account-deleted"),
name: "Account Deleted",
description: "Your account has been successfully deleted",
};
/**
* Settings page
* @returns JSX
*/
export const Settings: FC = () => {
// Hooks
const [beforeInstallPromptEvent, setBeforeInstallPromptEvent] =
useState<BeforeInstallPromptEvent>();
const [appInstalled, setAppInstalled] = useState(
window.matchMedia("(display-mode: standalone)").matches,
);
const appInstallInstructionsModal = useRef<HTMLIonModalElement>(null);
const setMessage = useEphemeralStore(state => state.setMessage);
const theme = usePersistentStore(state => state.theme);
const setTheme = usePersistentStore(state => state.setTheme);
const showFABs = usePersistentStore(state => state.showFABs);
const setShowFABs = usePersistentStore(state => state.setShowFABs);
const slidingActions = usePersistentStore(state => state.useSlidingActions);
const setSlidingActions = usePersistentStore(
state => state.setUseSlidingActions,
);
const showAmbientEffect = usePersistentStore(
state => state.showAmbientEffect,
);
const setShowAmbientEffect = usePersistentStore(
state => state.setShowAmbientEffect,
);
const measurementSystem = usePersistentStore(
state => state.measurementSystem,
);
const setMeasurementSystem = usePersistentStore(
state => state.setMeasurementSystem,
);
const reset = usePersistentStore(state => state.reset);
const history = useHistory();
const [present] = useIonActionSheet();
// Methods
/**
* Begin the app installation process
*/
const installApp = async () => {
await (beforeInstallPromptEvent === undefined
? appInstallInstructionsModal.current?.present()
: beforeInstallPromptEvent.prompt());
};
/**
* Reset all settings
* @returns Promise
*/
const resetSettings = () =>
present({
header: "Reset all settings",
subHeader:
"Are you sure you want to reset all settings? You will be signed out.",
buttons: [
{
text: "Cancel",
role: "cancel",
},
{
text: "Reset",
role: "destructive",
handler: reset,
},
],
});
/**
* Sign out
* @returns Promise
*/
const signOut = () =>
present({
header: "Sign out",
subHeader: "Are you sure you want to sign out?",
buttons: [
{
text: "Cancel",
role: "cancel",
},
{
text: "Sign out",
role: "destructive",
/**
* Sign out handler
*/
handler: async () => {
// Sign out
await client.auth.signOut();
// Redirect to the home
history.push("/");
},
},
],
});
/**
* Delete account
* @returns Promise
*/
const deleteAccount = () =>
present({
header: "Delete Your Account",
subHeader:
"Are you sure you want to delete your account? This action cannot be undone.",
buttons: [
{
text: "Cancel",
role: "cancel",
},
{
text: "Delete My Account",
role: "destructive",
/**
* Delete account handler
*/
handler: async () => {
// Delete the account
const {error} = await client.rpc("delete_account");
// Handle error
if (error) {
return;
}
// Sign out
await client.auth.signOut();
// Redirect to the home
history.push("/");
// Display the message
setMessage(ACCOUNT_DELETED_MESSAGE_METADATA);
},
},
],
});
// Effects
useEffect(() => {
// Capture the installed event
window.addEventListener("appinstalled", () => setAppInstalled(true));
// Capture the installable event
window.addEventListener(
"beforeinstallprompt",
event => {
event.preventDefault();
setBeforeInstallPromptEvent(event as BeforeInstallPromptEvent);
},
{
once: true,
},
);
}, []);
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Settings</IonTitle>
</IonToolbar>
</IonHeader>
<IonModal
className={styles.modal}
ref={appInstallInstructionsModal}
initialBreakpoint={1}
breakpoints={[0, 1]}
>
<div className="flex flex-col items-center justify-center p-4 w-full">
<h2 className="font-bold mb-2 text-center text-lg">
App Install Instructions
</h2>
<p className="mb-2">
This website is a Progresive Web App (PWA), meaning it has app
functionality. You can install it on your device by following the
below instructions:
</p>
<ol className="list-decimal ml-4">
{(() => {
if (isPlatform("ios")) {
return (
<>
<li>
Press the share button on the menu bar below.
<div className="my-4 w-full">
<SquareAndArrowUp className="dark:fill-[#4693ff] dark:stroke-[#4693ff] fill-[#007aff] h-16 mx-auto stroke-[#007aff] w-16" />
</div>
</li>
<li>
Select <q>Add to Home Screen</q>.
<div className="my-4 w-full">
<PlusApp className="dark:fill-white dark:stroke-white fill-black h-14 mx-auto stroke-black w-14" />
</div>
</li>
</>
);
} else if (isPlatform("android")) {
return (
<>
<li>
Press the three dots on the menu bar above.
<div className="my-4 w-full">
<IonIcon
className="block dark:fill-white dark:stroke-white fill-black h-14 mx-auto stroke-black w-14"
icon={ellipsisVertical}
/>
</div>
</li>
<li>
Select <q>Add to Home screen</q>.
<div className="my-4 w-full">
<AddToHomeScreen className="dark:fill-white dark:stroke-white fill-black h-16 mx-auto stroke-black w-16" />
</div>
</li>
</>
);
} else {
return (
<>
<li>
Open your browser&apos;s menu.
<div className="my-4 w-full">
<IonIcon
className="block dark:fill-white dark:stroke-white fill-black h-16 mx-auto stroke-black w-16"
icon={menuSharp}
/>
</div>
</li>
<li>
Select <q>Add to Home Screen</q>, <q>Install Beacon</q>,
or similar option.
<div className="my-4 w-full">
<AddToHomeScreen className="dark:fill-white dark:stroke-white fill-black h-16 mx-auto stroke-black w-16" />
</div>
</li>
</>
);
}
})()}
</ol>
</div>
</IonModal>
<IonContent color="light">
<IonList className="py-0" inset={true}>
<IonItemGroup>
<IonItemDivider>
<IonLabel>Look and Feel</IonLabel>
</IonItemDivider>
<IonItem>
<IonSelect
interface="action-sheet"
interfaceOptions={{
header: "Theme",
subHeader: "Select your preferred theme",
}}
label="Theme"
labelPlacement="floating"
onIonChange={event => setTheme(event.detail.value)}
value={theme}
>
<IonSelectOption value={Theme.LIGHT}>Light</IonSelectOption>
<IonSelectOption value={Theme.DARK}>Dark</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonToggle
checked={showFABs}
onIonChange={event => setShowFABs(event.detail.checked)}
>
Show floating action buttons
</IonToggle>
</IonItem>
<IonItem>
<IonToggle
checked={slidingActions}
onIonChange={event => setSlidingActions(event.detail.checked)}
>
Use sliding actions on posts
</IonToggle>
</IonItem>
<IonItem>
<IonToggle
checked={showAmbientEffect}
onIonChange={event =>
setShowAmbientEffect(event.detail.checked)
}
>
Show ambient effect below posts
</IonToggle>
</IonItem>
<IonItem>
<IonSelect
interface="action-sheet"
interfaceOptions={{
header: "Measurement system",
subHeader: "Select your preferred measurement system",
}}
label="Measurement system"
labelPlacement="floating"
onIonChange={event => setMeasurementSystem(event.detail.value)}
value={measurementSystem}
>
<IonSelectOption value={MeasurementSystem.METRIC}>
Metric
</IonSelectOption>
<IonSelectOption value={MeasurementSystem.IMPERIAL}>
Imperial
</IonSelectOption>
</IonSelect>
</IonItem>
</IonItemGroup>
<IonItemGroup>
<IonItemDivider>
<IonLabel>Miscellaneous</IonLabel>
</IonItemDivider>
<IonItem button={true} disabled={appInstalled} onClick={installApp}>
<IonLabel>
Install app {appInstalled ? "(Already Installed)" : ""}
</IonLabel>
<IonIcon
color="success"
slot="end"
ios={downloadOutline}
md={downloadSharp}
/>
</IonItem>
<IonItem button={true} onClick={resetSettings}>
<IonLabel>Reset all settings</IonLabel>
<IonIcon
color="danger"
slot="end"
ios={refreshOutline}
md={refreshSharp}
/>
</IonItem>
</IonItemGroup>
<IonItemGroup>
<IonItemDivider>
<IonLabel>Account</IonLabel>
</IonItemDivider>
<IonItem button={true} onClick={signOut}>
<IonLabel>Sign out</IonLabel>
<IonIcon
color="danger"
slot="end"
ios={logOutOutline}
md={logOutSharp}
/>
</IonItem>
<IonItem button={true} onClick={deleteAccount}>
<IonLabel>Delete Account</IonLabel>
<IonIcon
color="danger"
slot="end"
ios={warningOutline}
md={warningSharp}
/>
</IonItem>
</IonItemGroup>
<IonItemGroup>
<IonItemDivider>
<IonLabel>About</IonLabel>
</IonItemDivider>
<IonItem>
<IonLabel>Version</IonLabel>
<IonNote slot="end">{VERSION}</IonNote>
</IonItem>
<IonItem>
<IonLabel>Branch</IonLabel>
<IonNote slot="end">{GIT_BRANCH}</IonNote>
</IonItem>
<IonItem>
<IonLabel>Commit</IonLabel>
<IonNote slot="end">{GIT_COMMIT}</IonNote>
</IonItem>
</IonItemGroup>
<IonItemGroup>
<IonItemDivider>
<IonLabel>Links</IonLabel>
</IonItemDivider>
<IonItem routerLink="/faq">
<IonLabel>Frequently Asked Questions</IonLabel>
</IonItem>
<IonItem routerLink="/terms-and-conditions">
<IonLabel>Terms and Conditions</IonLabel>
</IonItem>
<IonItem routerLink="/privacy-policy">
<IonLabel>Privacy Policy</IonLabel>
</IonItem>
<IonItem
rel="noreferrer"
target="_blank"
href="https://github.com/ColoradoSchoolOfMines/Beacon"
>
<IonLabel>Source code</IonLabel>
</IonItem>
<IonItem
rel="noreferrer"
target="_blank"
href="https://github.com/ColoradoSchoolOfMines/beacon/issues/new/choose"
>
<IonLabel>Bug report/feature request</IonLabel>
</IonItem>
</IonItemGroup>
</IonList>
</IonContent>
</IonPage>
);
};