init commit,
This commit is contained in:
144
99_references/beacon-main/src/pages/auth/step1.tsx
Normal file
144
99_references/beacon-main/src/pages/auth/step1.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
138
99_references/beacon-main/src/pages/auth/step2.tsx
Normal file
138
99_references/beacon-main/src/pages/auth/step2.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
115
99_references/beacon-main/src/pages/auth/step3.tsx
Normal file
115
99_references/beacon-main/src/pages/auth/step3.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
54
99_references/beacon-main/src/pages/error.tsx
Normal file
54
99_references/beacon-main/src/pages/error.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
3
99_references/beacon-main/src/pages/index.module.css
Normal file
3
99_references/beacon-main/src/pages/index.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.snapContent::part(scroll) {
|
||||
@apply <md:snap-y <md:snap-mandatory;
|
||||
}
|
||||
221
99_references/beacon-main/src/pages/index.tsx
Normal file
221
99_references/beacon-main/src/pages/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
72
99_references/beacon-main/src/pages/markdown.tsx
Normal file
72
99_references/beacon-main/src/pages/markdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
207
99_references/beacon-main/src/pages/nearby.tsx
Normal file
207
99_references/beacon-main/src/pages/nearby.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
313
99_references/beacon-main/src/pages/posts/[id]/index.tsx
Normal file
313
99_references/beacon-main/src/pages/posts/[id]/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
529
99_references/beacon-main/src/pages/posts/create/step1.tsx
Normal file
529
99_references/beacon-main/src/pages/posts/create/step1.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
.range {
|
||||
--knob-size: 1.5rem;
|
||||
}
|
||||
345
99_references/beacon-main/src/pages/posts/create/step2.tsx
Normal file
345
99_references/beacon-main/src/pages/posts/create/step2.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
3
99_references/beacon-main/src/pages/settings.module.css
Normal file
3
99_references/beacon-main/src/pages/settings.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.modal {
|
||||
--height: auto;
|
||||
}
|
||||
511
99_references/beacon-main/src/pages/settings.tsx
Normal file
511
99_references/beacon-main/src/pages/settings.tsx
Normal 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'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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user