diff --git a/bun.lockb b/bun.lockb index db6fe8a..7361756 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 0c79e6e..4cc8657 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dependencies": { "@fontsource-variable/noto-sans-jp": "^5.0.19", "@generouted/react-router": "^1.19.3", + "@hookform/resolvers": "^3.9.0", "@iconify/react": "^4.1.1", "@nanostores/persistent": "^0.10.1", "@nanostores/react": "^0.7.2", @@ -66,6 +67,6 @@ "typescript": "^5.4.5", "vite": "^5.2.0", "vite-tsconfig-paths": "^4.3.2", - "wrangler": "^3.61.0" + "wrangler": "^3.73.0" } } diff --git a/src/components/achievements/Card.tsx b/src/components/achievements/Card.tsx index 6be41ab..9ceacc4 100644 --- a/src/components/achievements/Card.tsx +++ b/src/components/achievements/Card.tsx @@ -86,8 +86,12 @@ export function AchievementCard({ - #{achievement.tags[0].name} - #{achievement.tags[0].name} + {achievement.tags.map((t, idx) => ( + // eslint-disable-next-line react/no-array-index-key + + #{t} + + ))} ); } diff --git a/src/components/achievements/UnlockableCard.tsx b/src/components/achievements/UnlockableCard.tsx index 6ffa85b..91bc864 100644 --- a/src/components/achievements/UnlockableCard.tsx +++ b/src/components/achievements/UnlockableCard.tsx @@ -98,8 +98,8 @@ export function UnlockableCard({ - #{achievement.tags[0].name} - #{achievement.tags[0].name} + #{achievement.tags} + #{achievement.tags} ); } diff --git a/src/lib/consts.ts b/src/lib/consts.ts index e68d6fc..c35a3fc 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -34,4 +34,4 @@ export function getLocalStorageKey(key: string, trailingColon = false): string { return `${APP_NAME}.v${LOCAL_STORAGE_VERSION}.${key}${trailingColon ? ":" : ""}`; } -export const DB_VERSION = "1"; +export const DB_VERSION = "2"; diff --git a/src/pages/achievements/[id]/index.tsx b/src/pages/achievements/[id]/index.tsx index c420353..2da42ba 100644 --- a/src/pages/achievements/[id]/index.tsx +++ b/src/pages/achievements/[id]/index.tsx @@ -27,7 +27,7 @@ export default function Page(): ReactElement {

icon: {d.icon}

createdAt: {String(d.createdAt)}

updatedAt: {String(d.updatedAt)}

-

tags: {d.tags.map((tag) => tag.name).join(", ")}

+

tags: {d.tags.join(", ")}

); } diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 1f72a0d..7b67420 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -1,5 +1,6 @@ /* eslint-disable react-refresh/only-export-components */ +import { yupResolver } from "@hookform/resolvers/yup"; import { Icon } from "@iconify/react"; import { TextField, @@ -11,9 +12,13 @@ import { Popover, } from "@radix-ui/themes"; import { useState, type ReactElement } from "react"; -import { useForm, type SubmitHandler } from "react-hook-form"; +import { type SubmitHandler, useForm } from "react-hook-form"; import styled from "styled-components"; -import { type Achievement } from "@/types/post-data/achievements"; +import { match } from "ts-pattern"; +import { useAchievements } from "@/hooks/db/achievements"; +import { useTeam } from "@/hooks/teams"; +import yup from "@/lib/yup-locate"; +import { type Achievement, yAchievement } from "@/types/post-data/achievements"; const FormStyle = styled(Flex)` margin-top: 4rem; @@ -34,7 +39,7 @@ const AvatarStyle = styled(Avatar)` border: 10px solid #e7e7e7; `; -const Button1 = styled(Box)` +const SubmitButton = styled.input` font-weight: 600; font-family: sans-serif; font-size: 1rem; @@ -57,6 +62,7 @@ const Button1 = styled(Box)` overflow: hidden; position: relative; z-index: 1; + cursor: pointer; box-shadow: 6px 6px 16px #b5bec9, @@ -92,6 +98,15 @@ const Button1 = styled(Box)` transform: scaleX(100%); transform: none; } + + &:disabled { + cursor: not-allowed; + opacity: 0.3; + color: #ffffff; + background-color: #00cdc2; + transform: scale(1.06); + box-shadow: none; + } `; const PlusButton = styled(IconButton)` @@ -108,7 +123,7 @@ const PlusButton = styled(IconButton)` } `; -const ImputStyle = styled(TextField.Root)` +const TextInput = styled(TextField.Root)<{ invalid: number }>` position: relative; background-color: #e7e7e7; margin-top: 0.6rem; @@ -119,50 +134,120 @@ const ImputStyle = styled(TextField.Root)` margin-left: 0.4rem; color: #737a89; } + border: 2px solid ${({ invalid }) => (invalid !== 0 ? "#e03b3b" : "unset")}; `; -export default function create(): ReactElement { - const [selectIcon, setSelectIcon] = useState( - "https://api.iconify.design/twemoji:trophy.svg", - ); +const MessageContainer = styled(Flex)` + justify-content: center; + align-items: center; + font-size: 0.8rem; + gap: 10px; +`; - const { register, handleSubmit, setValue } = useForm({ - mode: "onSubmit", +const ErrorMessageContainer = styled(MessageContainer)` + color: #e03b3b; +`; + +const SuccessMessageContainer = styled(MessageContainer)` + color: #00cdc2; +`; + +const ErrorMessage = styled(Text)` + color: #e03b3b; + font-size: 0.8rem; +`; + +const yAchievementForm = yAchievement.concat( + yup.object({ + id: yup.mixed().notRequired(), + createdAt: yup.mixed().notRequired(), + updatedAt: yup.mixed().notRequired(), + }), +); + +const ICON_URLS = [ + "https://api.iconify.design/twemoji:trophy.svg", + "https://api.iconify.design/twemoji:meat-on-bone.svg", + "https://api.iconify.design/twemoji:horse-racing-medium-skin-tone.svg", + "https://api.iconify.design/twemoji:steaming-bowl.svg", + "https://api.iconify.design/twemoji:shopping-cart.svg", + "https://api.iconify.design/twemoji:page-facing-up.svg", + "https://api.iconify.design/twemoji:laptop.svg", + "https://api.iconify.design/twemoji:zombie.svg", + "https://api.iconify.design/twemoji:face-with-symbols-on-mouth.svg", + "https://api.iconify.design/twemoji:broken-heart.svg", + "https://api.iconify.design/twemoji:fire.svg", + "https://api.iconify.design/twemoji:beer-mug.svg", + "https://api.iconify.design/twemoji:beetle.svg", + "https://api.iconify.design/twemoji:bathtub.svg", + "https://api.iconify.design/twemoji:broccoli.svg", + "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgUqLcDsoa_vMqHK_IPFR4GoMT9RYnH6gtzw9nqHl2AfJeQI7Bm6vd2LphkvWznofSU0yXGcFCWEmO1owcCDJKqaijH4sDyK6r7gwjHUoqD-lVYxHPO9m6khg559gSY2FVv9qia_dHQPxbQ/s800/school_tani_otosu_boy.png", + "https://qr.paps.jp/8o3Og", + "https://i.imgur.com/5TaVIlf.gif", + "https://qr.paps.jp/fblo0", + "https://i.gifer.com/9ZNS.gif", +] as const; + +export default function Page(): ReactElement { + const { + register, + handleSubmit, + setValue, + getValues, + formState, + setError, + reset, + } = useForm({ + mode: "onBlur", + reValidateMode: "onChange", + resolver: yupResolver(yAchievementForm), + defaultValues: { + icon: ICON_URLS[0], + }, }); - const onSubmit: SubmitHandler = (data) => { - // eslint-disable-next-line no-console - console.log(data); - }; + const { errors, isSubmitting } = formState; + const { fetch, update } = useAchievements(useTeam); + + const [isPopoverOpened, setPopoverOpened] = useState(false); - const iconUrl = [ - "https://api.iconify.design/twemoji:trophy.svg", - "https://api.iconify.design/twemoji:meat-on-bone.svg", - "https://api.iconify.design/twemoji:horse-racing-medium-skin-tone.svg", - "https://api.iconify.design/twemoji:steaming-bowl.svg", - "https://api.iconify.design/twemoji:shopping-cart.svg", - "https://api.iconify.design/twemoji:page-facing-up.svg", - "https://api.iconify.design/twemoji:laptop.svg", - "https://api.iconify.design/twemoji:zombie.svg", - "https://api.iconify.design/twemoji:face-with-symbols-on-mouth.svg", - "https://api.iconify.design/twemoji:broken-heart.svg", - "https://api.iconify.design/twemoji:fire.svg", - "https://api.iconify.design/twemoji:beer-mug.svg", - "https://api.iconify.design/twemoji:beetle.svg", - "https://api.iconify.design/twemoji:bathtub.svg", - "https://api.iconify.design/twemoji:broccoli.svg", - "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgUqLcDsoa_vMqHK_IPFR4GoMT9RYnH6gtzw9nqHl2AfJeQI7Bm6vd2LphkvWznofSU0yXGcFCWEmO1owcCDJKqaijH4sDyK6r7gwjHUoqD-lVYxHPO9m6khg559gSY2FVv9qia_dHQPxbQ/s800/school_tani_otosu_boy.png", - "https://qr.paps.jp/8o3Og", - "https://i.imgur.com/5TaVIlf.gif", - "https://qr.paps.jp/fblo0", - "https://i.gifer.com/9ZNS.gif", - ]; + const onSubmit: SubmitHandler< + yup.InferType + > = async (data) => { + try { + const achievements = await fetch(); + if (achievements == null) { + throw new Error( + "`achievements` is null! Maybe you forgot to call `init()`", + ); + } + + const achievement: Achievement = { + ...data, + tags: data.tags.filter((tag) => tag !== ""), + id: achievements.length + 1, + createdAt: new Date(), + updatedAt: new Date(), + }; + + await update([...achievements, achievement]); + reset(); + } catch (e) { + setError("root.submit", { message: String(e) }); + throw e; + } + }; return ( - // eslint-disable-next-line @typescript-eslint/no-misused-promises -
+ { + // eslint-disable-next-line no-console + console.error("Form validation failed", e); + })} + > - + @@ -170,18 +255,20 @@ export default function create(): ReactElement { - {iconUrl.map((url, index) => ( + {ICON_URLS.map((url) => ( { - setSelectIcon(url); setValue("icon", url); + setPopoverOpened(false); }} + radius="full" + size="4" + value={getValues("icon")} variant="ghost" {...register("icon")} - size="4" > @@ -194,65 +281,104 @@ export default function create(): ReactElement { mb="4vh" mt="4vh" size="9" - src={selectIcon} + src={getValues("icon")} /> 実績名 - + {errors.name?.message ?? ""} 実績につけるタグ - + {errors.tags?.message ?? ""} 実績につけるタグ - - 実績の詳細 + 実績の説明 - + {errors?.description?.message ?? ""} - - - + + {match(formState) + .with({ isDirty: false }, () => undefined) + .with({ isSubmitting: true }, () => ( + + +

実績を追加中...

+
+ )) + .with({ isSubmitSuccessful: true }, () => ( + + +

実績は正常に追加されました

+
+ )) + .when( + () => errors.root?.submit != null, + () => ( + + + + 追加中にエラーが発生しました:{" "} + {errors.root?.submit.message ?? ""} + + + ), + ) + .otherwise(() => undefined)}
); diff --git a/src/types/post-data/achievements.ts b/src/types/post-data/achievements.ts index 2d23a45..d6cdb49 100644 --- a/src/types/post-data/achievements.ts +++ b/src/types/post-data/achievements.ts @@ -6,16 +6,7 @@ export const yAchievement = yup.object().shape({ name: yup.string().required(), description: yup.string().required(), icon: yup.string().required(), - tags: yup - .array() - .of( - yup.object().shape({ - id: yup.number().required(), - name: yup.string().required(), - color: yup.string().required(), - }), - ) - .required(), + tags: yup.array().of(yup.string()).required(), createdAt: yup.date().required(), updatedAt: yup.date().required(), });