From 1826e0caea5a79ca99164d6a6fb470a6d3df7815 Mon Sep 17 00:00:00 2001 From: hvrain Date: Wed, 12 Mar 2025 09:28:24 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20axios,=20tanstack=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20&=20=EC=84=9C=EB=B2=84=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../popup/src/hooks/query/use-create-todo.ts | 15 +++++++ pages/popup/src/hooks/use-infinite-scroll.ts | 39 +++++++++++++++++++ pages/popup/src/hooks/use-selector.ts | 27 +++++++++++++ pages/popup/src/lib/axios.ts | 7 ++-- .../popup/src/lib/tanstack-query-provider.tsx | 8 ++++ pages/popup/src/services/auth/index.ts | 3 -- pages/popup/src/services/endpoint.ts | 6 +++ pages/popup/src/services/goals/index.ts | 14 +++++++ pages/popup/src/services/goals/query.ts | 18 +++++++++ pages/popup/src/services/invalidate.ts | 11 ++++++ pages/popup/src/services/query-key.ts | 25 ++++++++++++ pages/popup/src/services/todo/index.ts | 15 +++++++ pages/popup/src/types/goal.ts | 25 ++++++++++++ pages/popup/src/types/index.ts | 5 +++ pages/popup/src/types/todo.ts | 29 ++++++++++++++ 15 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 pages/popup/src/hooks/query/use-create-todo.ts create mode 100644 pages/popup/src/hooks/use-infinite-scroll.ts create mode 100644 pages/popup/src/hooks/use-selector.ts create mode 100644 pages/popup/src/lib/tanstack-query-provider.tsx create mode 100644 pages/popup/src/services/goals/index.ts create mode 100644 pages/popup/src/services/goals/query.ts create mode 100644 pages/popup/src/services/invalidate.ts create mode 100644 pages/popup/src/services/query-key.ts create mode 100644 pages/popup/src/services/todo/index.ts create mode 100644 pages/popup/src/types/goal.ts create mode 100644 pages/popup/src/types/index.ts create mode 100644 pages/popup/src/types/todo.ts diff --git a/pages/popup/src/hooks/query/use-create-todo.ts b/pages/popup/src/hooks/query/use-create-todo.ts new file mode 100644 index 0000000..1c4c4a6 --- /dev/null +++ b/pages/popup/src/hooks/query/use-create-todo.ts @@ -0,0 +1,15 @@ +import { invalidateTodoRelatedQueries } from '@src/services/invalidate'; +import type { TodoParams } from '@src/services/todo'; +import { createTodo } from '@src/services/todo'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +export const useCreateTodo = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (newTodo: TodoParams) => createTodo(newTodo), + onSuccess: data => { + invalidateTodoRelatedQueries(queryClient, data.goalInformation?.id); + }, + }); +}; diff --git a/pages/popup/src/hooks/use-infinite-scroll.ts b/pages/popup/src/hooks/use-infinite-scroll.ts new file mode 100644 index 0000000..7269f7e --- /dev/null +++ b/pages/popup/src/hooks/use-infinite-scroll.ts @@ -0,0 +1,39 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { InfiniteQueryObserverResult } from '@tanstack/react-query'; + +interface useIntersectionObserverProps { + threshold?: number; + hasNextPage: boolean | undefined; + fetchNextPage: () => Promise; +} + +export const useInfiniteScroll = ({ threshold = 0.1, hasNextPage, fetchNextPage }: useIntersectionObserverProps) => { + const [rerender, setRerender] = useState(false); + const ref = useRef(null); + + // 첫 화면 렌더링 때 포함되지 않는 태그를 감지하기 위해 사용 + const refTrigger = useCallback(() => { + setRerender(prev => !prev); + }, []); + + const observerCallback: IntersectionObserverCallback = useCallback( + entries => { + entries.forEach(entry => { + if (entry.isIntersecting && hasNextPage) fetchNextPage(); + }); + }, + [hasNextPage, fetchNextPage], + ); + + useEffect(() => { + if (!ref.current) return; + const observer = new IntersectionObserver(observerCallback, { + threshold, + }); + observer.observe(ref.current); + return () => observer.disconnect(); + }, [observerCallback, threshold, rerender]); + + return { ref, refTrigger }; +}; diff --git a/pages/popup/src/hooks/use-selector.ts b/pages/popup/src/hooks/use-selector.ts new file mode 100644 index 0000000..06c4ec3 --- /dev/null +++ b/pages/popup/src/hooks/use-selector.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +const useSelector = () => { + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + + const toggleHandler = () => setIsOpen(!isOpen); + + const handleClickOutside = useCallback( + (e: React.BaseSyntheticEvent | MouseEvent) => { + if (ref.current && !ref.current.contains(e.target)) setIsOpen(prev => !prev); + }, + [ref, setIsOpen], + ); + + useEffect(() => { + if (isOpen) { + window.addEventListener('click', handleClickOutside); + return () => window.removeEventListener('click', handleClickOutside); + } + return; + }, [isOpen, handleClickOutside]); + + return { isOpen, ref, toggleHandler }; +}; + +export default useSelector; diff --git a/pages/popup/src/lib/axios.ts b/pages/popup/src/lib/axios.ts index c488326..f77a317 100644 --- a/pages/popup/src/lib/axios.ts +++ b/pages/popup/src/lib/axios.ts @@ -2,13 +2,13 @@ import axios, { AxiosError } from 'axios'; import { ENDPOINT } from '@src/services/endpoint'; import { ApiError } from './error'; -import { useAuthStore } from '@src/store/auth-store'; import { getConfigWithAuthorizationHeaders, reissueAccessToken, retryRequestWithNewToken, } from '@src/services/auth/token'; import { storeAccessTokenInCookie } from '@src/services/auth/route-handler'; +import { authStorage } from '@extension/storage'; const api = axios.create({ baseURL: process.env.CEB_SERVER_ADDRESS, @@ -23,7 +23,7 @@ api.interceptors.request.use( return config; } - const { accessToken } = useAuthStore.getState(); + const accessToken = await authStorage.get(); if (accessToken) { return getConfigWithAuthorizationHeaders(config, accessToken); @@ -56,8 +56,7 @@ api.interceptors.response.use( throw new Error('토큰 갱신에 실패했습니다.'); } catch { - const { removeAccessToken } = useAuthStore.getState(); - removeAccessToken(); + await authStorage.removeAccessToken(); return Promise.reject(new ApiError('로그인이 필요합니다.')); } diff --git a/pages/popup/src/lib/tanstack-query-provider.tsx b/pages/popup/src/lib/tanstack-query-provider.tsx new file mode 100644 index 0000000..7bbd737 --- /dev/null +++ b/pages/popup/src/lib/tanstack-query-provider.tsx @@ -0,0 +1,8 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import getQueryClient from './getQueryClient'; + +export default function Providers({ children }: { children: React.ReactNode }) { + const queryClient = getQueryClient(); + + return {children}; +} diff --git a/pages/popup/src/services/auth/index.ts b/pages/popup/src/services/auth/index.ts index 0f168ea..e866776 100644 --- a/pages/popup/src/services/auth/index.ts +++ b/pages/popup/src/services/auth/index.ts @@ -1,7 +1,6 @@ import type { AxiosResponse } from 'axios'; import { ENDPOINT } from '../endpoint'; import api from '@src/lib/axios'; -import { useAuthStore } from '@src/store/auth-store'; import { useUserStore } from '@src/store/user-store'; import type { LoginResponse } from '@src/types/auth'; @@ -12,10 +11,8 @@ export const login = async (email: string, password: string): Promise { + const { data } = await api.get(ENDPOINT.GOAL.GET_ALL, { + params: { + cursor, + size, + sortOrder, + }, + }); + return data; +}; diff --git a/pages/popup/src/services/goals/query.ts b/pages/popup/src/services/goals/query.ts new file mode 100644 index 0000000..71daf13 --- /dev/null +++ b/pages/popup/src/services/goals/query.ts @@ -0,0 +1,18 @@ +import { infiniteQueryOptions } from '@tanstack/react-query'; +import { getInfiniteGoals } from '.'; +import type { GoalListParams } from '@src/types/goal'; +import { goalKeys } from '../query-key'; + +export const getInfiniteGoalsOptions = ({ size = 3, sortOrder = 'newest' }: GoalListParams) => { + return infiniteQueryOptions({ + queryKey: goalKeys.list(size), + queryFn: ({ pageParam }) => getInfiniteGoals({ size, sortOrder, cursor: pageParam }), + getNextPageParam: lastPage => + lastPage.paginationInformation.hasNext ? lastPage.paginationInformation.nextCursor : null, + initialPageParam: 0, + select: data => ({ + pages: data.pages.flatMap(page => page.goals), + pageParams: data.pageParams, + }), + }); +}; diff --git a/pages/popup/src/services/invalidate.ts b/pages/popup/src/services/invalidate.ts new file mode 100644 index 0000000..718cef1 --- /dev/null +++ b/pages/popup/src/services/invalidate.ts @@ -0,0 +1,11 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { dashboardKeys, goalKeys, todoKeys } from './query-key'; + +export const invalidateTodoRelatedQueries = (queryClient: QueryClient, goalId?: number) => { + queryClient.invalidateQueries({ queryKey: todoKeys.all }); + queryClient.invalidateQueries({ queryKey: dashboardKeys.recent() }); + + if (goalId) { + queryClient.invalidateQueries({ queryKey: goalKeys.progress(goalId) }); + } +}; diff --git a/pages/popup/src/services/query-key.ts b/pages/popup/src/services/query-key.ts new file mode 100644 index 0000000..d1d45bd --- /dev/null +++ b/pages/popup/src/services/query-key.ts @@ -0,0 +1,25 @@ +import type { TodosByGoalParams } from '@src/types/todo'; + +export const goalKeys = { + all: ['goals'] as const, + list: (size: number) => [...goalKeys.all, 'list', { size }] as const, + detail: (id: number) => [...goalKeys.all, 'detail', { goalId: id }] as const, + progress: (id: number) => [...goalKeys.all, 'progress', { goalId: id }] as const, +}; + +export const todoKeys = { + all: ['todos'] as const, + list: ({ size, goalId, done }: TodosByGoalParams) => [...todoKeys.all, 'list', { size, goalId, done }] as const, + detail: (id: number) => [...todoKeys.all, 'detail', { todoId: id }] as const, +}; + +export const dashboardKeys = { + all: ['dashboard'] as const, + recent: () => [...dashboardKeys.all, 'todos', 'recent'] as const, + progress: () => [...dashboardKeys.all, 'progress'] as const, +}; + +export const noteKeys = { + all: ['notes'] as const, + detail: (id: number) => [...noteKeys.all, 'detail', { noteId: id }] as const, +}; diff --git a/pages/popup/src/services/todo/index.ts b/pages/popup/src/services/todo/index.ts new file mode 100644 index 0000000..3c1a51e --- /dev/null +++ b/pages/popup/src/services/todo/index.ts @@ -0,0 +1,15 @@ +import api from '@src/lib/axios'; +import type { TodoResponse } from '@src/types/todo'; +import { ENDPOINT } from '../endpoint'; + +export interface TodoParams { + goalId?: number; + title: TodoResponse['title']; + fileUrl?: TodoResponse['fileUrl']; + linkUrl?: TodoResponse['linkUrl']; +} + +export const createTodo = async (newTodo: TodoParams) => { + const { data } = await api.post(ENDPOINT.TODO.CREATE, newTodo); + return data; +}; diff --git a/pages/popup/src/types/goal.ts b/pages/popup/src/types/goal.ts new file mode 100644 index 0000000..38946ee --- /dev/null +++ b/pages/popup/src/types/goal.ts @@ -0,0 +1,25 @@ +import type { CommonPaginationInformationResponse } from '.'; + +export interface GoalResponse { + id: number; + title: string; + createdAt: string; + updatedAt: string; +} + +export interface GoalSimpleResponse { + id: number; + title: string; +} + +export interface GoalListResponse { + paginationInformation: CommonPaginationInformationResponse; + goals: GoalResponse[]; +} + +export interface GoalListParams { + cursor?: number; + size?: number; + sortOrder?: 'newest' | 'oldest'; + moreKeys?: string[]; +} diff --git a/pages/popup/src/types/index.ts b/pages/popup/src/types/index.ts new file mode 100644 index 0000000..0caf916 --- /dev/null +++ b/pages/popup/src/types/index.ts @@ -0,0 +1,5 @@ +export interface CommonPaginationInformationResponse { + nextCursor: number; + totalCount: number; + hasNext: boolean; +} diff --git a/pages/popup/src/types/todo.ts b/pages/popup/src/types/todo.ts new file mode 100644 index 0000000..d094982 --- /dev/null +++ b/pages/popup/src/types/todo.ts @@ -0,0 +1,29 @@ +import type { GoalSimpleResponse } from './goal'; + +export interface TodosByGoalParams { + goalId?: number; + cursor?: number; + size?: number; + done?: boolean; +} + +export interface TodoResponse { + id: number; + noteId: number | null; + title: string; + goalInformation: GoalSimpleResponse | null; + linkUrl: string | null; + fileUrl: string | null; + isDone: boolean; + createdAt: string; + updatedAt: string; +} + +export interface TodoListResponse { + paginationInformation: { + nextCursor: number | null; + totalCount: number; + hasNext: boolean; + }; + todos: TodoResponse[]; +} From 4423c9db1fdc6124d55a124a176f588974532eef Mon Sep 17 00:00:00 2001 From: hvrain Date: Wed, 12 Mar 2025 09:30:22 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20extension=20storage=EB=A1=9C=20acce?= =?UTF-8?q?ssToken=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/storage/lib/impl/authStorage.ts | 25 +++++++++++++++++ packages/storage/lib/impl/index.ts | 1 + pages/popup/src/Router.tsx | 31 +++++++++++++-------- pages/popup/src/components/Login.tsx | 6 +++- pages/popup/src/components/PrivateRoute.tsx | 4 ++- pages/popup/src/store/auth-store.ts | 19 ------------- 6 files changed, 54 insertions(+), 32 deletions(-) create mode 100644 packages/storage/lib/impl/authStorage.ts delete mode 100644 pages/popup/src/store/auth-store.ts diff --git a/packages/storage/lib/impl/authStorage.ts b/packages/storage/lib/impl/authStorage.ts new file mode 100644 index 0000000..cfdf284 --- /dev/null +++ b/packages/storage/lib/impl/authStorage.ts @@ -0,0 +1,25 @@ +import type { BaseStorage } from '../base/index.js'; +import { createStorage, StorageEnum } from '../base/index.js'; + +type AccessToken = string | null; + +type AccessTokenStorage = BaseStorage & { + setAccessToken: (newAccessToken: string) => Promise; + removeAccessToken: () => Promise; +}; + +const storage = createStorage('access-token-key', null, { + storageEnum: StorageEnum.Local, + liveUpdate: true, +}); + +// You can extend it with your own methods +export const authStorage: AccessTokenStorage = { + ...storage, + setAccessToken: async (newAccessToken: string) => { + await storage.set(newAccessToken); + }, + removeAccessToken: async () => { + await storage.set(null); + }, +}; diff --git a/packages/storage/lib/impl/index.ts b/packages/storage/lib/impl/index.ts index 49f6f17..3aad9b4 100644 --- a/packages/storage/lib/impl/index.ts +++ b/packages/storage/lib/impl/index.ts @@ -1 +1,2 @@ export * from './exampleThemeStorage.js'; +export * from './authStorage.js'; diff --git a/pages/popup/src/Router.tsx b/pages/popup/src/Router.tsx index 90ac518..9462b22 100644 --- a/pages/popup/src/Router.tsx +++ b/pages/popup/src/Router.tsx @@ -4,7 +4,9 @@ import type { Dispatch, PropsWithChildren, SetStateAction } from 'react'; import { createContext, useContext, useEffect, useState } from 'react'; import Login from './components/Login'; import PrivateRoute from './components/PrivateRoute'; - +import { useStorage } from '@extension/shared'; +import { authStorage } from '@extension/storage'; +import TanstackQueryProvider from '@src/lib/tanstack-query-provider'; interface AuthContextProps { authenticated: boolean; setAuthenticated: Dispatch>; @@ -25,10 +27,15 @@ export function useAuthContext() { export const AuthProvider = ({ children }: PropsWithChildren) => { const [authenticated, setAuthenticated] = useState(true); + const auth = useStorage(authStorage); useEffect(() => { - // 토큰이 있는지 검사 - }, []); + if (auth) { + setAuthenticated(true); + } else { + setAuthenticated(false); + } + }, [auth]); return {children}; }; @@ -36,14 +43,16 @@ export const AuthProvider = ({ children }: PropsWithChildren) => { export default function Router() { return ( - - - }> - } /> - - } /> - - + + + + }> + } /> + + } /> + + + ); } diff --git a/pages/popup/src/components/Login.tsx b/pages/popup/src/components/Login.tsx index faf8bb2..1e23e2f 100644 --- a/pages/popup/src/components/Login.tsx +++ b/pages/popup/src/components/Login.tsx @@ -9,6 +9,7 @@ import { login } from '@src/services/auth'; import { ApiError } from '@src/lib/error'; import { useNavigate } from 'react-router'; import { LOGIN_ERROR_CODE } from '@src/constants/error'; +import { authStorage } from '@extension/storage'; const loginSchema = z.object({ email: z.string().nonempty('이메일을 입력해주세요.').email('이메일 형식을 입력해주세요.'), @@ -50,7 +51,10 @@ export default function Login() { const onSubmit = async (data: LoginSchema) => { try { - await login(data.email, data.password); + const { data: res } = await login(data.email, data.password); + + const { accessToken } = res.credentials; + authStorage.setAccessToken(accessToken); navigate('/'); } catch (error: unknown) { diff --git a/pages/popup/src/components/PrivateRoute.tsx b/pages/popup/src/components/PrivateRoute.tsx index 1950741..0ad5903 100644 --- a/pages/popup/src/components/PrivateRoute.tsx +++ b/pages/popup/src/components/PrivateRoute.tsx @@ -1,7 +1,9 @@ +import { useStorage } from '@extension/shared'; +import { authStorage } from '@extension/storage'; import { Navigate, Outlet } from 'react-router'; const PrivateRoute = () => { - const auth = null; // determine if authorized, from context or however you're doing it + const auth = useStorage(authStorage); // If authorized, return an outlet that will render child elements // If not, return element that will navigate to login page diff --git a/pages/popup/src/store/auth-store.ts b/pages/popup/src/store/auth-store.ts deleted file mode 100644 index 0e8feb7..0000000 --- a/pages/popup/src/store/auth-store.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { create } from 'zustand'; - -interface AuthState { - accessToken: string | null; - - setAccessToken: (token: string) => void; - removeAccessToken: () => void; -} - -const initialState = { - accessToken: null, -}; - -export const useAuthStore = create()(set => ({ - ...initialState, - - setAccessToken: (token: string) => set({ accessToken: token }), - removeAccessToken: () => set(initialState), -})); From 14142a1e39aaf78a947b7e5dd29ba8bdbf59d5b3 Mon Sep 17 00:00:00 2001 From: hvrain Date: Wed, 12 Mar 2025 09:31:28 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=ED=95=A0=EC=9D=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chrome-extension/manifest.ts | 2 +- packages/hmr/lib/consts.ts | 2 +- pages/popup/public/arrow_dropdown.svg | 13 +++ pages/popup/public/arrow_dropdown_reverse.svg | 13 +++ pages/popup/src/Popup.tsx | 100 +++++++++++++----- pages/popup/src/components/goal-dropdown.tsx | 84 +++++++++++++++ pages/popup/src/components/todo-create.tsx | 58 ++++++++++ 7 files changed, 241 insertions(+), 31 deletions(-) create mode 100644 pages/popup/public/arrow_dropdown.svg create mode 100644 pages/popup/public/arrow_dropdown_reverse.svg create mode 100644 pages/popup/src/components/goal-dropdown.tsx create mode 100644 pages/popup/src/components/todo-create.tsx diff --git a/chrome-extension/manifest.ts b/chrome-extension/manifest.ts index e76f6cb..71de986 100644 --- a/chrome-extension/manifest.ts +++ b/chrome-extension/manifest.ts @@ -30,7 +30,7 @@ const manifest = { version: packageJson.version, description: '__MSG_extensionDescription__', host_permissions: [''], - permissions: ['storage', 'scripting', 'tabs', 'notifications'], + permissions: ['storage', 'scripting', 'tabs', 'notifications', 'cookies'], background: { service_worker: 'background.js', type: 'module', diff --git a/packages/hmr/lib/consts.ts b/packages/hmr/lib/consts.ts index 8166866..3e3b314 100644 --- a/packages/hmr/lib/consts.ts +++ b/packages/hmr/lib/consts.ts @@ -1,4 +1,4 @@ -export const LOCAL_RELOAD_SOCKET_PORT = 8081; +export const LOCAL_RELOAD_SOCKET_PORT = 8083; export const LOCAL_RELOAD_SOCKET_URL = `ws://localhost:${LOCAL_RELOAD_SOCKET_PORT}`; export const DO_UPDATE = 'do_update'; diff --git a/pages/popup/public/arrow_dropdown.svg b/pages/popup/public/arrow_dropdown.svg new file mode 100644 index 0000000..f9fb91e --- /dev/null +++ b/pages/popup/public/arrow_dropdown.svg @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/pages/popup/public/arrow_dropdown_reverse.svg b/pages/popup/public/arrow_dropdown_reverse.svg new file mode 100644 index 0000000..c16a3dc --- /dev/null +++ b/pages/popup/public/arrow_dropdown_reverse.svg @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/pages/popup/src/Popup.tsx b/pages/popup/src/Popup.tsx index 4bd21b4..b42d3ba 100644 --- a/pages/popup/src/Popup.tsx +++ b/pages/popup/src/Popup.tsx @@ -1,38 +1,80 @@ import { withErrorBoundary, withSuspense } from '@extension/shared'; -import Input from '@src/components/Input'; -import { useEffect, useState } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { Dispatch, SetStateAction } from 'react'; +import { createContext, useContext, useMemo, useState } from 'react'; +import type { + FieldErrors, + UseFormHandleSubmit, + UseFormRegister, + UseFormSetValue, + UseFormTrigger, + UseFormWatch, +} from 'react-hook-form'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import TodoCreate from './components/todo-create'; -function Popup() { - const [link, setLink] = useState(''); +export const todoFormSchema = z.object({ + title: z.string().min(1, '제목을 입력해주세요').max(30, '30자 이내로 입력해주세요'), + link: z.union([z.literal(''), z.string().trim().url('올바른 URL을 입력해주세요')]), +}); + +export type TodoFormSchema = z.infer; + +interface TodoModalFormContextProps { + register: UseFormRegister; + watch: UseFormWatch; + formState: { + errors: FieldErrors; + isValid: boolean; + }; + trigger: UseFormTrigger; + handleSubmit: UseFormHandleSubmit; + setValue: UseFormSetValue; + selectedGoalId?: number; + setSelectedGoalId: Dispatch>; +} - const logo = 'popup/logo.svg'; - const goGithubSite = () => chrome.tabs.create({ url: 'https://buildone.me' }); +const TodoFormContext = createContext(null); - useEffect(() => { - const getPageUrl = () => { - chrome.tabs.query({ active: true, currentWindow: true }, tabs => { - const { url } = tabs[0]; - setLink(url!); - }); - }; - getPageUrl(); - }, []); +export const useTodoFormContext = () => { + const context = useContext(TodoFormContext); + if (!context) { + throw new Error('할일 모달 폼 내부에서 context를 사용해주세요요'); + } + return context; +}; + +function Popup() { + const { register, handleSubmit, watch, setValue, formState, trigger, getValues } = useForm({ + resolver: zodResolver(todoFormSchema), + mode: 'onBlur', + defaultValues: { + title: '', + link: '', + }, + }); + console.log(getValues()); + const [selectedGoalId, setSelectedGoalId] = useState(); + + const formContextValue = useMemo( + () => ({ + handleSubmit, + setValue, + register, + watch, + formState, + trigger, + selectedGoalId, + setSelectedGoalId, + }), + [register, watch, formState, trigger, handleSubmit, setValue, selectedGoalId], + ); return ( -
-
- -
-
-
- - - {/*목표 선택은 추후에 구현*/} -
-
-
+ + + ); } diff --git a/pages/popup/src/components/goal-dropdown.tsx b/pages/popup/src/components/goal-dropdown.tsx new file mode 100644 index 0000000..94875f3 --- /dev/null +++ b/pages/popup/src/components/goal-dropdown.tsx @@ -0,0 +1,84 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import Label from './Label'; +import { cn } from '@extension/ui'; +import type { MouseEvent } from 'react'; +import { useEffect } from 'react'; +import { getInfiniteGoalsOptions } from '@src/services/goals/query'; +import useSelector from '@src/hooks/use-selector'; +import { useInfiniteScroll } from '@src/hooks/use-infinite-scroll'; +import { useTodoFormContext } from '@src/Popup'; + +const BASE_CLASS = + 'flex items-center justify-center space-x-8 rounded-xl border border-slate-50 bg-slate-50 px-24 py-12 text-base font-normal focus-within:border-dark-blue-500 hover:border-dark-blue-300'; + +export default function GoalDropdown() { + const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(getInfiniteGoalsOptions({ size: 20 })); + const { ref: scrollRef, refTrigger } = useInfiniteScroll({ + hasNextPage, + fetchNextPage, + }); + + const arrowIcon = 'popup/arrow_dropdown.svg'; + const arrowReverseIcon = 'popup/arrow_dropdown_reverse.svg'; + + const { isOpen, ref, toggleHandler } = useSelector(); + const { selectedGoalId, setSelectedGoalId } = useTodoFormContext(); + + const goalTitle = data?.pages.find(page => page.id === selectedGoalId)?.title; + + const handleSelectGoal = (e: MouseEvent) => { + const dataset = e.currentTarget.dataset as { goal: string }; + const goalIdData = dataset.goal; + setSelectedGoalId(Number(goalIdData)); + toggleHandler(); + }; + + useEffect(() => { + refTrigger(); + }, [isOpen, refTrigger]); + + return ( +
{ + if (e.key === 'Enter') { + toggleHandler(); + } + }}> +
+ ); +} diff --git a/pages/popup/src/components/todo-create.tsx b/pages/popup/src/components/todo-create.tsx new file mode 100644 index 0000000..b70dfba --- /dev/null +++ b/pages/popup/src/components/todo-create.tsx @@ -0,0 +1,58 @@ +import Input from '@src/components/Input'; +import { useCreateTodo } from '@src/hooks/query/use-create-todo'; +import { useEffect } from 'react'; +import GoalDropdown from './goal-dropdown'; +import Button from './button'; +import type { TodoFormSchema } from '@src/Popup'; +import { useTodoFormContext } from '@src/Popup'; + +export default function TodoCreate() { + const { mutate } = useCreateTodo(); + const { + formState: { isValid }, + handleSubmit, + selectedGoalId, + watch, + setValue, + register, + } = useTodoFormContext(); + const link = watch('link'); + + const logo = 'popup/logo.svg'; + const goBuilDoneSite = () => chrome.tabs.create({ url: 'https://buildone.me' }); + + const onSubmit = (data: TodoFormSchema) => { + const { title, link } = data; + mutate({ title, linkUrl: link, goalId: selectedGoalId }); + }; + + useEffect(() => { + const getPageUrl = () => { + chrome.tabs.query({ active: true, currentWindow: true }, tabs => { + const { url } = tabs[0]; + setValue('link', url!); + }); + }; + getPageUrl(); + }, [setValue]); + + return ( +
+
+ +
+
+
+ + + + + +
+
+ ); +} From 330428ab3e1feef7e5b7bb67e914a2b4c2e41cba Mon Sep 17 00:00:00 2001 From: hvrain Date: Wed, 12 Mar 2025 10:18:30 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20axios=20interceptor=EC=97=90?= =?UTF-8?q?=EC=84=9C=20token=EC=9D=84=20storage=EC=97=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/popup/src/Popup.tsx | 3 +-- pages/popup/src/lib/axios.ts | 3 +-- pages/popup/src/services/auth/route-handler.ts | 11 ----------- pages/popup/src/services/auth/token.ts | 3 --- 4 files changed, 2 insertions(+), 18 deletions(-) delete mode 100644 pages/popup/src/services/auth/route-handler.ts diff --git a/pages/popup/src/Popup.tsx b/pages/popup/src/Popup.tsx index b42d3ba..6c2aed5 100644 --- a/pages/popup/src/Popup.tsx +++ b/pages/popup/src/Popup.tsx @@ -46,7 +46,7 @@ export const useTodoFormContext = () => { }; function Popup() { - const { register, handleSubmit, watch, setValue, formState, trigger, getValues } = useForm({ + const { register, handleSubmit, watch, setValue, formState, trigger } = useForm({ resolver: zodResolver(todoFormSchema), mode: 'onBlur', defaultValues: { @@ -54,7 +54,6 @@ function Popup() { link: '', }, }); - console.log(getValues()); const [selectedGoalId, setSelectedGoalId] = useState(); const formContextValue = useMemo( diff --git a/pages/popup/src/lib/axios.ts b/pages/popup/src/lib/axios.ts index f77a317..2e99d70 100644 --- a/pages/popup/src/lib/axios.ts +++ b/pages/popup/src/lib/axios.ts @@ -7,7 +7,6 @@ import { reissueAccessToken, retryRequestWithNewToken, } from '@src/services/auth/token'; -import { storeAccessTokenInCookie } from '@src/services/auth/route-handler'; import { authStorage } from '@extension/storage'; const api = axios.create({ @@ -32,7 +31,7 @@ api.interceptors.request.use( const newAccessToken = await reissueAccessToken(); if (newAccessToken) { - await storeAccessTokenInCookie(newAccessToken); + await authStorage.setAccessToken(newAccessToken); return getConfigWithAuthorizationHeaders(config, newAccessToken); } diff --git a/pages/popup/src/services/auth/route-handler.ts b/pages/popup/src/services/auth/route-handler.ts deleted file mode 100644 index fa7dc7b..0000000 --- a/pages/popup/src/services/auth/route-handler.ts +++ /dev/null @@ -1,11 +0,0 @@ -import axios from 'axios'; -import { ENDPOINT } from '../endpoint'; - -/** accessToken 쿠키 저장 API */ -export const storeAccessTokenInCookie = async (token: string) => { - const res = await axios.post(ENDPOINT.AUTH.LOGIN, { - accessToken: token, - }); - - return res; -}; diff --git a/pages/popup/src/services/auth/token.ts b/pages/popup/src/services/auth/token.ts index 9ce0b26..3dbd6ed 100644 --- a/pages/popup/src/services/auth/token.ts +++ b/pages/popup/src/services/auth/token.ts @@ -3,7 +3,6 @@ import axios from 'axios'; import { ENDPOINT } from '../endpoint'; -import { storeAccessTokenInCookie } from './route-handler'; import type { ReissueAccessTokenResponse } from '@src/types/auth'; /** 기존 config에 Authorization 헤더 추가 */ @@ -28,8 +27,6 @@ export const reissueAccessToken = async (): Promise => { throw new Error('토큰이 응답에 포함되지 않았습니다.'); } - await storeAccessTokenInCookie(token); - return token; } catch { return null; From def6f46bde6869c3cdde4b78d0af2bd398f862cf Mon Sep 17 00:00:00 2001 From: hvrain Date: Wed, 12 Mar 2025 11:39:08 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EC=9A=94=EC=B2=AD=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EA=B0=80=20=EC=83=9D=EA=B2=A8=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 2 +- chrome-extension/manifest.ts | 2 +- pages/popup/src/components/Login.tsx | 1 + pages/popup/src/lib/axios.ts | 27 +++----------------------- pages/popup/src/services/auth/index.ts | 1 - pages/popup/src/services/auth/token.ts | 1 - 6 files changed, 6 insertions(+), 28 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index b3261f1..e19064d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "cSpell.words": ["Buil"] + "cSpell.words": ["Buil", "buildone"] } diff --git a/chrome-extension/manifest.ts b/chrome-extension/manifest.ts index 71de986..4e4d1a9 100644 --- a/chrome-extension/manifest.ts +++ b/chrome-extension/manifest.ts @@ -29,7 +29,7 @@ const manifest = { }, version: packageJson.version, description: '__MSG_extensionDescription__', - host_permissions: [''], + host_permissions: ['*://*.buildone.me/'], permissions: ['storage', 'scripting', 'tabs', 'notifications', 'cookies'], background: { service_worker: 'background.js', diff --git a/pages/popup/src/components/Login.tsx b/pages/popup/src/components/Login.tsx index 1e23e2f..7a380f2 100644 --- a/pages/popup/src/components/Login.tsx +++ b/pages/popup/src/components/Login.tsx @@ -58,6 +58,7 @@ export default function Login() { navigate('/'); } catch (error: unknown) { + console.log(error); if (error instanceof ApiError) { if ( error.code === LOGIN_ERROR_CODE.INVALID_EMAIL_FORMAT || diff --git a/pages/popup/src/lib/axios.ts b/pages/popup/src/lib/axios.ts index 2e99d70..37629c0 100644 --- a/pages/popup/src/lib/axios.ts +++ b/pages/popup/src/lib/axios.ts @@ -2,11 +2,7 @@ import axios, { AxiosError } from 'axios'; import { ENDPOINT } from '@src/services/endpoint'; import { ApiError } from './error'; -import { - getConfigWithAuthorizationHeaders, - reissueAccessToken, - retryRequestWithNewToken, -} from '@src/services/auth/token'; +import { getConfigWithAuthorizationHeaders } from '@src/services/auth/token'; import { authStorage } from '@extension/storage'; const api = axios.create({ @@ -28,13 +24,6 @@ api.interceptors.request.use( return getConfigWithAuthorizationHeaders(config, accessToken); } - const newAccessToken = await reissueAccessToken(); - - if (newAccessToken) { - await authStorage.setAccessToken(newAccessToken); - return getConfigWithAuthorizationHeaders(config, newAccessToken); - } - return config; }, (error: unknown) => { @@ -46,19 +35,9 @@ api.interceptors.response.use( response => response, async (error: unknown) => { if (error instanceof AxiosError && error.response?.status === 401) { - try { - const newToken = await reissueAccessToken(); - - if (newToken) { - return await retryRequestWithNewToken(error.config!, newToken, api); - } - - throw new Error('토큰 갱신에 실패했습니다.'); - } catch { - await authStorage.removeAccessToken(); + await authStorage.removeAccessToken(); - return Promise.reject(new ApiError('로그인이 필요합니다.')); - } + return Promise.reject(new ApiError('로그인이 필요합니다.')); } else { return Promise.reject(new ApiError(error)); } diff --git a/pages/popup/src/services/auth/index.ts b/pages/popup/src/services/auth/index.ts index e866776..9541dfe 100644 --- a/pages/popup/src/services/auth/index.ts +++ b/pages/popup/src/services/auth/index.ts @@ -9,7 +9,6 @@ export const login = async (email: string, password: string): Promise => { if (!token) { throw new Error('토큰이 응답에 포함되지 않았습니다.'); } - return token; } catch { return null; From 2c8bb9452960ee72282d5e9b34d195a713e70b86 Mon Sep 17 00:00:00 2001 From: hvrain Date: Wed, 12 Mar 2025 13:24:18 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20figma=20=EB=94=94=EC=9E=90=EC=9D=B8?= =?UTF-8?q?=20=EC=8B=9C=EC=95=88=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/lib/global.css | 2 +- pages/popup/src/Router.tsx | 16 ++++++++++++++++ pages/popup/src/components/Input.tsx | 8 +++++--- pages/popup/src/components/Label.tsx | 8 +++----- pages/popup/src/components/button.tsx | 4 ++-- pages/popup/src/components/goal-dropdown.tsx | 10 +++++----- pages/popup/src/components/todo-create.tsx | 14 ++++++++------ pages/popup/src/index.css | 5 +++-- 8 files changed, 43 insertions(+), 24 deletions(-) diff --git a/packages/ui/lib/global.css b/packages/ui/lib/global.css index b5c61c9..bd6213e 100644 --- a/packages/ui/lib/global.css +++ b/packages/ui/lib/global.css @@ -1,3 +1,3 @@ @tailwind base; @tailwind components; -@tailwind utilities; +@tailwind utilities; \ No newline at end of file diff --git a/pages/popup/src/Router.tsx b/pages/popup/src/Router.tsx index 9462b22..8c8ae79 100644 --- a/pages/popup/src/Router.tsx +++ b/pages/popup/src/Router.tsx @@ -7,6 +7,7 @@ import PrivateRoute from './components/PrivateRoute'; import { useStorage } from '@extension/shared'; import { authStorage } from '@extension/storage'; import TanstackQueryProvider from '@src/lib/tanstack-query-provider'; +//import { reissueAccessToken } from './services/auth/token'; interface AuthContextProps { authenticated: boolean; setAuthenticated: Dispatch>; @@ -29,6 +30,21 @@ export const AuthProvider = ({ children }: PropsWithChildren) => { const [authenticated, setAuthenticated] = useState(true); const auth = useStorage(authStorage); + //useEffect(() => { + // const getNewToken = async () => { + // const newAccessToken = await reissueAccessToken(); + // if (newAccessToken) { + // return await authStorage.setAccessToken(newAccessToken); + // } + // chrome.cookies.getAll({ url: 'https://buildone.me' }, cookies => { + // console.log(cookies); + // }); + // }; + // if (!auth) { + // getNewToken(); + // } + //}, [auth]); + useEffect(() => { if (auth) { setAuthenticated(true); diff --git a/pages/popup/src/components/Input.tsx b/pages/popup/src/components/Input.tsx index 16b013b..27d254d 100644 --- a/pages/popup/src/components/Input.tsx +++ b/pages/popup/src/components/Input.tsx @@ -1,11 +1,13 @@ 'use client'; -import { forwardRef, InputHTMLAttributes, Ref, useState } from 'react'; +import type { InputHTMLAttributes, Ref } from 'react'; +import { forwardRef, useState } from 'react'; import Label from './Label'; +import { cn } from '@extension/ui'; export const BASE_CLASS = - 'flex items-center justify-center space-x-8 rounded-xl border border-slate-50 bg-slate-50 px-24 py-12 text-base font-normal focus-within:border-dark-blue-500 hover:border-dark-blue-300'; + 'flex items-center justify-center space-x-8 rounded-xl border border-slate-50 bg-slate-50 px-24 py-12 font-normal focus-within:border-dark-blue-500 hover:border-dark-blue-300'; export const RESPONSIVE_CLASS = 'h-44 w-343 md:h-48 md:w-612'; @@ -25,7 +27,7 @@ export default forwardRef(function Input( return ( <> {label &&