diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index df3f424..dc0c6b5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,7 +7,7 @@ on: jobs: build: - name: Node.js ubuntu-latest 14.x + name: Node.js ubuntu-latest 20.x runs-on: ubuntu-latest steps: - name: Check out Git repository @@ -16,7 +16,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 20 - name: before cache run: | diff --git a/package.json b/package.json index d719070..69eee0b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "next": "13.5.6", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.49.2", "rehype-sanitize": "^6.0.0", "sass": "^1.69.5", "url-join": "^5.0.0" diff --git a/src/app/[slug]/[date]/page.tsx b/src/app/[slug]/[date]/page.tsx index 8f17377..8998c0b 100644 --- a/src/app/[slug]/[date]/page.tsx +++ b/src/app/[slug]/[date]/page.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import { NippoBreadcrumbs } from './_components/NippoBreadcrumbs'; import { getNippoByDate } from '~/app/_actions/nippoActions'; -import { getObjectiveBySlug } from '~/app/_actions/objectiveActions'; +import { getObjectiveBySlug, getObjectiveTasks } from '~/app/_actions/objectiveActions'; import { URLS } from '~/app/_constants/urls'; import { generateNippoMetadata } from '~/libs/generateNippoMetadata'; import { fetchMe } from '~/app/_actions/userActions'; @@ -9,6 +9,7 @@ import { NippoEditor } from '~/app/_components/domains/Nippo/NippoEditor'; import { getDateString } from '~/libs/getDateString'; import { NippoPreview } from '~/app/_components/domains/Nippo/NippoPreview'; import { NippoShareIcon } from '~/app/_components/domains/Nippo/NippoShareIcon'; +import { TaskList } from '~/app/_components/domains/Task/TaskList'; type Props = { params: { slug: string; date: string } }; @@ -25,6 +26,7 @@ export default async function Page({ params }: Props) { getNippoByDate(params.slug, params.date), fetchMe(), ]); + const { result: tasksPagination } = await getObjectiveTasks({ objectiveId: objective._id, page: 1 }); const dateString = getDateString(params.date); return ( @@ -32,15 +34,25 @@ export default async function Page({ params }: Props) {
-
-

{dateString}

- {nippo && } +
+
+
+

{dateString}

+ {nippo && } +
+ {currentUser?._id === objective.createdUserId ? ( + + ) : ( + + )} +
+ {/* TODO:一旦非表示 */} + {false && ( +
+ +
+ )}
- {currentUser?._id === objective.createdUserId ? ( - - ) : ( - - )}
diff --git a/src/app/_actions/objectiveActions.ts b/src/app/_actions/objectiveActions.ts index f46446f..6fb29ff 100644 --- a/src/app/_actions/objectiveActions.ts +++ b/src/app/_actions/objectiveActions.ts @@ -1,12 +1,20 @@ 'use server'; import { redirect } from 'next/navigation'; -import { API_OBJECTIVE, API_OBJECTIVE_ID, API_OBJECTIVE_ID_NIPPO, API_OBJECTIVE_ME, API_OBJECTIVE_SLUG } from '../_constants/apiUrls'; +import { + API_OBJECTIVE, + API_OBJECTIVE_ID, + API_OBJECTIVE_ID_NIPPO, + API_OBJECTIVE_ID_TASK, + API_OBJECTIVE_ME, + API_OBJECTIVE_SLUG, +} from '../_constants/apiUrls'; import { URLS } from '../_constants/urls'; import { Objective } from '~/domains/Objective'; import { apiGet, apiPost } from '~/libs/apiClient'; import { Nippo } from '~/domains/Nippo'; import { PaginationResult } from '~/domains/PaginationResult'; +import { Task } from '~/domains/Task'; export const getObjectiveMe = async () => { return await apiGet<{ objective: Objective }>(API_OBJECTIVE_ME()); @@ -47,3 +55,9 @@ export const getObjectiveNippos = async ({ next: isMyObjective ? undefined : { revalidate: 60 }, }); }; + +export const getObjectiveTasks = async ({ objectiveId, page }: { objectiveId: string; page: number }) => { + return await apiGet<{ result: PaginationResult }>(`${API_OBJECTIVE_ID_TASK(objectiveId)}?page=${page}`, { + cache: 'no-store', + }); +}; diff --git a/src/app/_actions/taskActions.ts b/src/app/_actions/taskActions.ts new file mode 100644 index 0000000..a4f81fc --- /dev/null +++ b/src/app/_actions/taskActions.ts @@ -0,0 +1,26 @@ +'use server'; + +import { API_TASK } from '../_constants/apiUrls'; +import { apiPost } from '~/libs/apiClient'; +import { Task } from '~/domains/Task'; + +export const postTask = async ({ + body, + title, + dueDate, + objectiveId, +}: { + body: string; + title: string; + dueDate: Date; + objectiveId: string; +}) => { + return await apiPost<{ task: Task }>(API_TASK(), { + body: JSON.stringify({ + body, + title, + dueDate, + objectiveId, + }), + }); +}; diff --git a/src/app/_components/domains/Nippo/NippoEditor/NippoEditor.tsx b/src/app/_components/domains/Nippo/NippoEditor/NippoEditor.tsx index a2815a8..f88f9c9 100644 --- a/src/app/_components/domains/Nippo/NippoEditor/NippoEditor.tsx +++ b/src/app/_components/domains/Nippo/NippoEditor/NippoEditor.tsx @@ -1,19 +1,9 @@ 'use client'; -import './styles.scss'; - -import { Color } from '@tiptap/extension-color'; -import { Link } from '@tiptap/extension-link'; -import { useEffect, useState, FC, useCallback } from 'react'; -import { EditorContent, useEditor } from '@tiptap/react'; -import TextStyle from '@tiptap/extension-text-style'; -import Placeholder from '@tiptap/extension-placeholder'; -import StarterKit from '@tiptap/starter-kit'; -import ListItem from '@tiptap/extension-list-item'; -import Heading from '@tiptap/extension-heading'; +import { FC, useCallback } from 'react'; import { postNippo } from '~/app/_actions/nippoActions'; import { Nippo } from '~/domains/Nippo'; -import { useDebounce } from '~/libs/useDebounce'; +import { DebounceEditor } from '~/app/_components/uiParts/Editor/DebounceEditor'; type Props = { objectiveId: string; @@ -22,9 +12,6 @@ type Props = { }; export const NippoEditor: FC = ({ objectiveId, nippo, date }) => { - const [inputText, setInputText] = useState(); - const debouncedInputText = useDebounce({ value: inputText, delay: 200 }); - const handleEditorChange = useCallback( async (body: string) => { await postNippo({ objectiveId, date, body }); @@ -32,44 +19,5 @@ export const NippoEditor: FC = ({ objectiveId, nippo, date }) => { [date, objectiveId], ); - useEffect(() => { - debouncedInputText && handleEditorChange(debouncedInputText); - }, [debouncedInputText, handleEditorChange]); - - const extensions = [ - Color.configure({ types: [TextStyle.name, ListItem.name] }), - Link.configure(), - StarterKit.configure({ - bulletList: { - keepMarks: true, - keepAttributes: false, - }, - orderedList: { - keepMarks: true, - keepAttributes: false, - }, - }), - Placeholder.configure({ - placeholder: '振り返りを記入しましょう!', - }), - Heading.configure({ - levels: [1, 2, 3, 4, 5], - }), - ]; - - const editor = useEditor({ - extensions, - content: nippo?.body, - autofocus: 'end', - onUpdate: ({ editor }) => { - setInputText(editor.getHTML()); - }, - editorProps: { - attributes: { - class: 'min-h-[400px]', - }, - }, - }); - - return ; + return ; }; diff --git a/src/app/_components/domains/Task/CreateTaskModal/CreateTaskModal.tsx b/src/app/_components/domains/Task/CreateTaskModal/CreateTaskModal.tsx new file mode 100644 index 0000000..2edd8ec --- /dev/null +++ b/src/app/_components/domains/Task/CreateTaskModal/CreateTaskModal.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { FC, useCallback, useState } from 'react'; +import { Button, Input, Modal, ModalBody, ModalContent, ModalFooter } from '@nextui-org/react'; +import { Controller, SubmitHandler, useForm } from 'react-hook-form'; +import { TaskEditor } from '../TaskEditor'; +import { postTask } from '~/app/_actions/taskActions'; + +type Props = { + isOpen: boolean; + onOpenChange: () => void; + objectiveId: string; +}; + +interface IFormInput { + title: string; + body: string; +} + +export const CreateModal: FC = ({ isOpen, onOpenChange, objectiveId }) => { + const [isLoading, setIsLoading] = useState(false); + + const { control, formState, handleSubmit, reset } = useForm({ + defaultValues: { + title: '', + body: '', + }, + }); + + const handleOpenChange = useCallback(() => { + reset(); + onOpenChange(); + }, [onOpenChange, reset]); + + const onSubmit: SubmitHandler = useCallback( + async (inputData) => { + if (isLoading) return; + setIsLoading(true); + postTask({ title: inputData.title, body: inputData.body, dueDate: new Date(), objectiveId }) + .catch((error) => { + // TODO: 本来はコンソールに出すのではなく、ユーザーにエラーを通知する + console.error(error); + }) + .then(() => { + handleOpenChange(); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [handleOpenChange, isLoading, objectiveId], + ); + + return ( + + + + ( + + )} + /> + field.onChange(body)} />} + /> + + + + + + + ); +}; diff --git a/src/app/_components/domains/Task/CreateTaskModal/index.ts b/src/app/_components/domains/Task/CreateTaskModal/index.ts new file mode 100644 index 0000000..15d0f3c --- /dev/null +++ b/src/app/_components/domains/Task/CreateTaskModal/index.ts @@ -0,0 +1 @@ +export { CreateModal } from './CreateTaskModal'; diff --git a/src/app/_components/domains/Task/TaskEditor/TaskEditor.tsx b/src/app/_components/domains/Task/TaskEditor/TaskEditor.tsx new file mode 100644 index 0000000..7185638 --- /dev/null +++ b/src/app/_components/domains/Task/TaskEditor/TaskEditor.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { FC } from 'react'; +import { Editor } from '~/app/_components/uiParts/Editor'; + +type Props = { + onChangeText: (body: string) => void; +}; + +export const TaskEditor: FC = ({ onChangeText }) => { + return ; +}; diff --git a/src/app/_components/domains/Task/TaskEditor/index.ts b/src/app/_components/domains/Task/TaskEditor/index.ts new file mode 100644 index 0000000..f83b3d0 --- /dev/null +++ b/src/app/_components/domains/Task/TaskEditor/index.ts @@ -0,0 +1 @@ +export { TaskEditor } from './TaskEditor'; diff --git a/src/app/_components/domains/Task/TaskList/TaskList.tsx b/src/app/_components/domains/Task/TaskList/TaskList.tsx new file mode 100644 index 0000000..1e6d5b8 --- /dev/null +++ b/src/app/_components/domains/Task/TaskList/TaskList.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { Button, Card, useDisclosure } from '@nextui-org/react'; +import { FC } from 'react'; +import { CreateModal } from '../CreateTaskModal'; +import { Icon } from '~/app/_components/uiParts/icons'; +import { Task } from '~/domains/Task'; + +type Props = { + objectiveId: string; + tasks: Task[]; +}; + +export const TaskList: FC = ({ objectiveId, tasks }) => { + const { isOpen, onOpen, onOpenChange } = useDisclosure(); + + return ( +
+

タスク一覧

+
+ {tasks.map((task) => { + return ( + + ここにタスク一覧が表示されます + + ); + })} +
+ + +
+ ); +}; diff --git a/src/app/_components/domains/Task/TaskList/index.ts b/src/app/_components/domains/Task/TaskList/index.ts new file mode 100644 index 0000000..19a80e9 --- /dev/null +++ b/src/app/_components/domains/Task/TaskList/index.ts @@ -0,0 +1 @@ +export { TaskList } from './TaskList'; diff --git a/src/app/_components/uiParts/Editor/DebounceEditor.tsx b/src/app/_components/uiParts/Editor/DebounceEditor.tsx new file mode 100644 index 0000000..ae7b14b --- /dev/null +++ b/src/app/_components/uiParts/Editor/DebounceEditor.tsx @@ -0,0 +1,31 @@ +'use client'; + +import './styles.scss'; + +import { useEffect, useState, FC, useCallback } from 'react'; +import { Editor } from './Editor'; +import { useDebounce } from '~/libs/useDebounce'; + +type Props = { + body?: string; + placeholder: string; + onChange: (body: string) => Promise; +}; + +export const DebounceEditor: FC = ({ body, placeholder, onChange }) => { + const [inputText, setInputText] = useState(); + const debouncedInputText = useDebounce({ value: inputText, delay: 200 }); + + const handleEditorChange = useCallback( + async (body: string) => { + await onChange(body); + }, + [onChange], + ); + + useEffect(() => { + debouncedInputText && handleEditorChange(debouncedInputText); + }, [debouncedInputText, handleEditorChange]); + + return setInputText(body)} />; +}; diff --git a/src/app/_components/uiParts/Editor/Editor.tsx b/src/app/_components/uiParts/Editor/Editor.tsx new file mode 100644 index 0000000..fab84e9 --- /dev/null +++ b/src/app/_components/uiParts/Editor/Editor.tsx @@ -0,0 +1,58 @@ +'use client'; + +import './styles.scss'; + +import { Color } from '@tiptap/extension-color'; +import { Link } from '@tiptap/extension-link'; +import { FC } from 'react'; +import { EditorContent, useEditor } from '@tiptap/react'; +import TextStyle from '@tiptap/extension-text-style'; +import Placeholder from '@tiptap/extension-placeholder'; +import StarterKit from '@tiptap/starter-kit'; +import ListItem from '@tiptap/extension-list-item'; +import Heading from '@tiptap/extension-heading'; + +type Props = { + body?: string; + placeholder: string; + onChange: (body: string) => void; +}; + +export const Editor: FC = ({ body, placeholder, onChange }) => { + const extensions = [ + Color.configure({ types: [TextStyle.name, ListItem.name] }), + Link.configure(), + StarterKit.configure({ + bulletList: { + keepMarks: true, + keepAttributes: false, + }, + orderedList: { + keepMarks: true, + keepAttributes: false, + }, + }), + Placeholder.configure({ + placeholder, + }), + Heading.configure({ + levels: [1, 2, 3, 4, 5], + }), + ]; + + const editor = useEditor({ + extensions, + content: body, + autofocus: 'end', + onUpdate: ({ editor }) => { + onChange(editor.getHTML()); + }, + editorProps: { + attributes: { + class: 'min-h-[400px]', + }, + }, + }); + + return ; +}; diff --git a/src/app/_components/uiParts/Editor/index.ts b/src/app/_components/uiParts/Editor/index.ts new file mode 100644 index 0000000..5ccde0e --- /dev/null +++ b/src/app/_components/uiParts/Editor/index.ts @@ -0,0 +1 @@ +export { Editor } from './Editor'; diff --git a/src/app/_components/domains/Nippo/NippoEditor/styles.scss b/src/app/_components/uiParts/Editor/styles.scss similarity index 100% rename from src/app/_components/domains/Nippo/NippoEditor/styles.scss rename to src/app/_components/uiParts/Editor/styles.scss diff --git a/src/app/_constants/apiUrls.ts b/src/app/_constants/apiUrls.ts index 23c6b6a..bcf0bcc 100644 --- a/src/app/_constants/apiUrls.ts +++ b/src/app/_constants/apiUrls.ts @@ -8,3 +8,7 @@ export const API_OBJECTIVE_ME = () => `/api/objectives/me`; export const API_OBJECTIVE_ID_NIPPO = (_id: string) => `/api/objectives/${_id}/nippos`; export const API_NIPPO_BY_DATE = (slug: string, data: string) => `/api/nippos/by-date?date=${data}&slug=${slug}`; + +export const API_OBJECTIVE_ID_TASK = (_id: string) => `/api/objectives/${_id}/tasks`; +export const API_TASK = () => `/api/tasks`; +export const API_TASK_ID = (id: string) => `/api/tasks/${id}`; diff --git a/src/domains/Task/Task.ts b/src/domains/Task/Task.ts new file mode 100644 index 0000000..5e62e6b --- /dev/null +++ b/src/domains/Task/Task.ts @@ -0,0 +1,10 @@ +export interface Task { + _id: string; + title: string; + body: string; + createdUserId: string; + objectiveId: string; + dueDate: string; // 対応日時 + createdAt: string; + updatedAt: string; +} diff --git a/src/domains/Task/index.ts b/src/domains/Task/index.ts new file mode 100644 index 0000000..9fc71d8 --- /dev/null +++ b/src/domains/Task/index.ts @@ -0,0 +1 @@ +export type { Task } from './Task'; diff --git a/yarn.lock b/yarn.lock index b742930..d6d9a00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4775,6 +4775,11 @@ react-dom@^18: loose-envify "^1.1.0" scheduler "^0.23.0" +react-hook-form@^7.49.2: + version "7.49.2" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.49.2.tgz#6fb2742e1308020f26cb1915c7012b6c07b11ade" + integrity sha512-TZcnSc17+LPPVpMRIDNVITY6w20deMdNi6iehTFLV1x8SqThXGwu93HjlUVU09pzFgZH7qZOvLMM7UYf2ShAHA== + react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"