feat: add create account flow

This commit is contained in:
2024-03-28 15:12:43 +07:00
parent d3fa59d2b1
commit cbbf5eaf50
18 changed files with 714 additions and 152 deletions

View File

@@ -1,17 +1,19 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { Dispatch, SetStateAction, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@lume/utils";
import { Dispatch, ReactNode, SetStateAction, useState } from "react";
import { toast } from "sonner";
export function AvatarUploader({
setPicture,
children,
className,
}: {
setPicture: Dispatch<SetStateAction<string>>;
children: ReactNode;
className?: string;
}) {
const ark = useArk();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {
@@ -32,15 +34,9 @@ export function AvatarUploader({
<button
type="button"
onClick={() => uploadAvatar()}
className="inline-flex w-32 items-center justify-center rounded-lg border border-blue-200 bg-blue-100 px-2 py-1.5 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800"
className={cn("", className)}
>
{loading ? (
<button type="button" className="size-4" disabled>
<LoaderIcon className="size-4 animate-spin" />
</button>
) : (
t("user.avatarButton")
)}
{loading ? <LoaderIcon className="size-4 animate-spin" /> : children}
</button>
);
}

View File

@@ -1,25 +0,0 @@
import { LoaderIcon } from "@lume/icons";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/auth/create")({
component: Screen,
loader: ({ context }) => {
return context.ark.create_keys();
},
pendingComponent: Pending,
});
function Screen() {
return <div className="px-5"></div>;
}
function Pending() {
return (
<div className="flex h-full w-full flex-col items-center gap-2">
<button type="button" className="size-5" disabled>
<LoaderIcon className="size-5 animate-spin" />
</button>
<p>Creating account</p>
</div>
);
}

View File

@@ -0,0 +1,186 @@
import { displayNsec } from "@lume/utils";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import * as Checkbox from "@radix-ui/react-checkbox";
import { CheckIcon } from "@lume/icons";
export const Route = createFileRoute("/auth/new/backup")({
component: Screen,
});
function Screen() {
// @ts-ignore, magic!!!
const { account } = Route.useSearch();
const { t } = useTranslation();
const [key, setKey] = useState(null);
const [passphase, setPassphase] = useState("");
const [copied, setCopied] = useState(false);
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
const navigate = useNavigate();
const submit = async () => {
try {
if (key) {
if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
return toast.warning("You need to confirm before continue");
} else {
return navigate({
to: "/auth/settings",
search: { account, new: true },
});
}
}
const encrypted: string = await invoke("get_encrypted_key", {
npub: account,
password: passphase,
});
setKey(encrypted);
} catch (e) {
toast.error(String(e));
}
};
const copyKey = async () => {
try {
await writeText(key);
setCopied(true);
} catch (e) {
toast.error(e);
}
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col text-center">
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
<p className="text-neutral-700 dark:text-neutral-300">
It's use for login to Lume or other Nostr clients. You will lost
access to your account if you lose this key.
</p>
</div>
<div className="flex w-full flex-col gap-5">
<div className="flex flex-col gap-2">
<label htmlFor="nsec" className="font-medium">
Set a passphase to secure your key
</label>
<div className="relative">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
</div>
</div>
{key ? (
<>
<div className="flex flex-col gap-2">
<label htmlFor="nsec" className="font-medium">
Copy this key and keep it in safe place
</label>
<div className="flex items-center gap-2">
<input
name="nsec"
type="text"
value={displayNsec(key, 36)}
readOnly
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
<button
type="button"
onClick={copyKey}
className="inline-flex h-11 w-24 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="font-medium">Before you continue:</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c1}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c1: !state.c1 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
id="confirm1"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm1"
>
{t("backup.confirm1")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c2}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c2: !state.c2 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
id="confirm2"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm2"
>
{t("backup.confirm2")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c3}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c3: !state.c3 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
id="confirm3"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm3"
>
{t("backup.confirm3")}
</label>
</div>
</div>
</div>
</>
) : null}
<div>
<button
type="button"
onClick={submit}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{t("global.continue")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import { AvatarUploader } from "@/components/avatarUploader";
import { useArk } from "@lume/ark";
import { LoaderIcon, PlusIcon } from "@lume/icons";
import { Metadata } from "@lume/types";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/profile")({
component: Screen,
loader: ({ context }) => {
return context.ark.create_keys();
},
});
function Screen() {
const ark = useArk();
const keys = Route.useLoaderData();
const navigate = useNavigate();
const { t } = useTranslation();
const { register, handleSubmit } = useForm();
const [picture, setPicture] = useState<string>("");
const [loading, setLoading] = useState(false);
const onSubmit = async (data: {
name: string;
about: string;
website: string;
}) => {
setLoading(true);
try {
// Save account keys
const save = await ark.save_account(keys.nsec);
// Then create profile
if (save) {
const profile: Metadata = { ...data, picture };
const eventId = await ark.create_profile(profile);
if (eventId) {
navigate({
to: "/auth/new/backup",
search: { account: keys.npub },
replace: true,
});
}
}
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
</div>
<div>
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{picture ? (
<img
src={picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</AvatarUploader>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex w-full flex-col gap-3"
>
<div className="flex flex-col gap-1">
<label htmlFor="display_name" className="font-medium">
{t("user.displayName")} *
</label>
<input
type={"text"}
{...register("display_name", { required: true, minLength: 1 })}
placeholder="e.g. Alice in Nostrland"
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium">
{t("user.name")}
</label>
<input
type={"text"}
{...register("name")}
placeholder="e.g. alice"
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="about" className="font-medium">
{t("user.bio")}
</label>
<textarea
{...register("about")}
placeholder="e.g. Artist, anime-lover, and k-pop fan"
spellCheck={false}
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="website" className="font-medium">
{t("user.website")}
</label>
<input
type="url"
{...register("website")}
placeholder="e.g. https://alice.me"
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<button
type="submit"
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
t("global.continue")
)}
</button>
</form>
</div>
);
}

View File

@@ -1,7 +1,6 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -32,9 +31,8 @@ function Screen() {
try {
const npub = await ark.save_account(key, password);
navigate({
to: "/$account/home",
params: { account: npub },
search: { onboarding: true },
to: "/auth/settings",
search: { account: npub, new: false },
replace: true,
});
} catch (e) {
@@ -45,57 +43,50 @@ function Screen() {
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-semibold">{t("login.title")}</h1>
<p className="text-lg leading-snug text-neutral-600 dark:text-neutral-500">
{t("login.subtitle")}
</p>
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label
htmlFor="key"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Private Key
</label>
<input
name="key"
type="text"
placeholder="nsec or ncryptsec..."
value={key}
onChange={(e) => setKey(e.target.value)}
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="password"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Password (Optional)
</label>
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
</div>
</div>
<button
type="button"
onClick={submit}
disabled={loading}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 text-lg font-medium text-white hover:bg-blue-600"
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
</div>
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="key"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
{loading ? <LoaderIcon className="size-5 animate-spin" /> : "Login"}
</button>
Private Key
</label>
<input
name="key"
type="text"
placeholder="nsec or ncryptsec..."
value={key}
onChange={(e) => setKey(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="password"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Password (Optional)
</label>
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<button
type="button"
onClick={submit}
disabled={loading}
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <LoaderIcon className="size-4 animate-spin" /> : "Login"}
</button>
</div>
</div>
);

View File

@@ -0,0 +1,164 @@
import { CheckIcon } from "@lume/icons";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import * as Switch from "@radix-ui/react-switch";
import { Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { Settings } from "@lume/types";
import { useArk } from "@lume/ark";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/settings")({
component: Screen,
});
function Screen() {
const ark = useArk();
// @ts-ignore, magic!!!
const { account } = Route.useSearch();
const { t } = useTranslation();
const [settings, setSettings] = useState<Settings>({
notification: false,
enhancedPrivacy: false,
autoUpdate: false,
});
const toggleNofitication = async () => {
await requestPermission();
setSettings((prev) => ({
...prev,
notification: !settings.notification,
}));
};
const toggleAutoUpdate = () => {
setSettings((prev) => ({
...prev,
autoUpdate: !settings.autoUpdate,
}));
};
const toggleEnhancedPrivacy = () => {
setSettings((prev) => ({
...prev,
enhancedPrivacy: !settings.enhancedPrivacy,
}));
};
const saveSettings = async () => {
try {
const eventId = await ark.set_settings(settings);
if (eventId) toast.success("Settings have been updated successfully.");
} catch (e) {
toast.error(e);
}
};
useEffect(() => {
async function loadSettings() {
const permissionGranted = await isPermissionGranted(); // get notification permission
const settings = await ark.get_settings(account);
setSettings({ ...settings, notification: permissionGranted });
}
loadSettings();
}, []);
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col items-center gap-5 text-center">
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 text-teal-500">
<CheckIcon className="size-6" />
</div>
<div>
<h1 className="text-xl font-semibold">
{t("onboardingSettings.title")}
</h1>
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
{t("onboardingSettings.subtitle")}
</p>
</div>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
<Switch.Root
checked={settings.notification}
onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div className="flex-1">
<h3 className="font-semibold">Push Notification</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Enabling push notifications will allow you to receive
notifications from Lume.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
<Switch.Root
checked={settings.enhancedPrivacy}
onClick={() => toggleEnhancedPrivacy()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div className="flex-1">
<h3 className="font-semibold">Enhanced Privacy</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Lume will display external resources like image, video or link
preview as plain text.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
<Switch.Root
checked={settings.autoUpdate}
onClick={() => toggleAutoUpdate()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div className="flex-1">
<h3 className="font-semibold">Auto Update</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically download and install new version.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-50 px-5 py-4 dark:bg-neutral-950">
<p className="text-sm text-neutral-700 dark:text-neutral-300">
There are many more settings you can configure from the 'Settings'
Screen. Be sure to visit it later.
</p>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={saveSettings}
className="inline-flex h-11 flex-1 items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 disabled:opacity-50 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Save settings
</button>
<Link
to="/$account/home"
params={{ account }}
className="inline-flex h-11 flex-1 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{t("global.continue")}
</Link>
</div>
</div>
</div>
);
}

View File

@@ -32,7 +32,7 @@ function Screen() {
</div>
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
<Link
to="/auth/create"
to="/auth/new/profile"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90"
>
{t("welcome.signup")}

View File

@@ -17,14 +17,8 @@ export function Screen() {
// @ts-ignore, just work!!!
const { id, name, account } = Route.useSearch();
const { t } = useTranslation();
const {
data,
hasNextPage,
isLoading,
isRefetching,
isFetchingNextPage,
fetchNextPage,
} = useEvents("local", account);
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useEvents("local", account);
const renderItem = (event: Event) => {
if (!event) return;
@@ -46,13 +40,17 @@ export function Screen() {
<LoaderIcon className="size-5 animate-spin" />
</button>
</div>
) : !data ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
) : !data.length ? (
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-100 p-5 dark:bg-neutral-900">
<InfoIcon className="size-6" />
<div>
<p className="leading-tight">{t("emptyFeedTitle")}</p>
<p className="leading-tight">{t("emptyFeedSubtitle")}</p>
<p className="font-medium leading-tight">
{t("global.emptyFeedTitle")}
</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
{t("global.emptyFeedSubtitle")}
</p>
</div>
</div>
<Suggest />
@@ -62,8 +60,9 @@ export function Screen() {
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
<div className="flex h-20 items-center justify-center">
{data?.length && hasNextPage ? (
{data?.length && hasNextPage ? (
<div className="flex h-20 items-center justify-center">
<button
type="button"
onClick={() => fetchNextPage()}
@@ -79,8 +78,8 @@ export function Screen() {
</>
)}
</button>
) : null}
</div>
</div>
) : null}
</Column.Content>
</Column.Root>
);