diff --git a/package.json b/package.json index dd0a5d8..198523c 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "react-dom": "^18" }, "devDependencies": { + "@octokit/openapi-types": "^19.0.0", "@octokit/rest": "^20.0.2", "@octokit/types": "^12.0.0", "@tanstack/react-query": "^5.0.0", @@ -39,7 +40,9 @@ "next-auth": "^4.24.3", "node-mocks-http": "^1.13.0", "postcss": "^8", + "react-daisyui": "^4.1.2", "react-icons": "^4.11.0", + "react-test-renderer": "^18.2.0", "sharp": "^0.32.6", "tailwindcss": "^3", "typescript": "^5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e242e5..b1937d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ dependencies: version: 18.2.0(react@18.2.0) devDependencies: + '@octokit/openapi-types': + specifier: ^19.0.0 + version: 19.0.0 '@octokit/rest': specifier: ^20.0.2 version: 20.0.2 @@ -85,9 +88,15 @@ devDependencies: postcss: specifier: ^8 version: 8.4.31 + react-daisyui: + specifier: ^4.1.2 + version: 4.1.2(daisyui@3.9.3)(react-dom@18.2.0)(react@18.2.0)(tailwindcss@3.3.3) react-icons: specifier: ^4.11.0 version: 4.11.0(react@18.2.0) + react-test-renderer: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) sharp: specifier: ^0.32.6 version: 0.32.6 @@ -3792,6 +3801,20 @@ packages: strip-json-comments: 2.0.1 dev: true + /react-daisyui@4.1.2(daisyui@3.9.3)(react-dom@18.2.0)(react@18.2.0)(tailwindcss@3.3.3): + resolution: {integrity: sha512-Sx8ziaxKDe/59bw+UxTFOoDSJEuA8iGhgmMbzSAtnhaaZPP20kluHG+1/wY5mBSxfcAuk6oI8fqKcJRp55WzPQ==} + peerDependencies: + daisyui: ^3.0.22 + react: '>=16' + react-dom: '>=16' + tailwindcss: '>=3.2.7' + dependencies: + daisyui: 3.9.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tailwindcss: 3.3.3 + dev: true + /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -3826,6 +3849,27 @@ packages: engines: {node: '>=0.10.0'} dev: true + /react-shallow-renderer@16.15.0(react@18.2.0): + resolution: {integrity: sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + object-assign: 4.1.1 + react: 18.2.0 + react-is: 18.2.0 + dev: true + + /react-test-renderer@18.2.0(react@18.2.0): + resolution: {integrity: sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==} + peerDependencies: + react: ^18.2.0 + dependencies: + react: 18.2.0 + react-is: 18.2.0 + react-shallow-renderer: 16.15.0(react@18.2.0) + scheduler: 0.23.0 + dev: true + /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} diff --git a/src/app/api/github/repo/route.ts b/src/app/api/github/repo/route.ts new file mode 100644 index 0000000..1edc2cf --- /dev/null +++ b/src/app/api/github/repo/route.ts @@ -0,0 +1,8 @@ +import search from '@/pkg/github/api/search' +import deleteRepo from '@/pkg/github/api/delete' +import { patch } from '@/pkg/github/api/patch' +export { + search as GET, + deleteRepo as DELETE, + patch as PATCH +} diff --git a/src/app/api/github/route.ts b/src/app/api/github/route.ts deleted file mode 100644 index e55a18d..0000000 --- a/src/app/api/github/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { deleteRepo } from '@/pkg/github/api/delete'; -import { patch } from '@/pkg/github/api/patch'; -export { - deleteRepo as DELETE, - patch as PATCH -} diff --git a/src/app/api/github/search/route.ts b/src/app/api/github/search/route.ts deleted file mode 100644 index e7814ea..0000000 --- a/src/app/api/github/search/route.ts +++ /dev/null @@ -1,5 +0,0 @@ -import search from '@/pkg/github/api/search' - -export { - search as POST -} diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx new file mode 100644 index 0000000..a312128 --- /dev/null +++ b/src/app/auth/layout.tsx @@ -0,0 +1,10 @@ +import LayoutFull from '@/pkg/ui/views/LayoutFull'; +import { PropsWithChildren } from 'react'; + +export default function Layout({children}: PropsWithChildren) { + return ( + + {children} + + ) +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..d82d42a --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -0,0 +1,10 @@ +import LayoutDefault from '@/pkg/ui/views/LayoutDefault'; +import { PropsWithChildren } from 'react'; + +export default function Layout({children}: PropsWithChildren) { + return ( + + {children} + + ) +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 2e73c30..9df63cd 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,6 +1,6 @@ -import Dashboard from '@/pkg/github/components/Dashboard' +import GitHubDashboard from '@/pkg/github/pages/DashboardPage' export default function DashboardPage() { - return + return GitHubDashboard() } diff --git a/src/app/globals.css b/src/app/globals.css index a90f074..e406c48 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,3 +2,20 @@ @tailwind components; @tailwind utilities; +@layer components { + .pagination > button { + @apply btn btn-xs; + } + + .pagination > button.nav { + @apply bg-slate-950 text-white; + } + + .pagination > button.number { + @apply btn-outline; + } + + .pagination > button > svg { + @apply w-4 h-4; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9334c4e..7410630 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,11 +2,8 @@ import type { Metadata } from 'next' import { Inter } from 'next/font/google' import './globals.css' import classNames from 'classnames' -import ComposeProviders from '@/pkg/ui/ComposeProviders' -import ThemeProvider from '@/pkg/ui/contexts/ThemeContext' -import AuthProvider from '@/pkg/auth/context/AuthContext' -import ToastProvider from '@/pkg/ui/contexts/ToastContext' -import ReactQueryProvider from '@/pkg/ui/contexts/ReactQueryContext' +import Layout from '@/pkg/ui/views/Layout' +import Providers from '@/pkg/ui/Providers' const inter = Inter({ subsets: ['latin'] }) @@ -27,14 +24,9 @@ export default function RootLayout({ 'bg-gray-200 min-h-screen': true })} > - + {children} - + ) diff --git a/src/pkg/github/GitHub.ts b/src/pkg/github/GitHub.ts new file mode 100644 index 0000000..322d957 --- /dev/null +++ b/src/pkg/github/GitHub.ts @@ -0,0 +1,14 @@ +import { GitHubEnumSortOrder } from './types'; + +export const GitHub = { + search: { + perPage: 6, + sort: 'updated', + order: GitHubEnumSortOrder.asc, + cacheKey: 'github_search_params', + queryKey: 'gitHubSearch' + }, + fetch: { + queryKey: 'gitHubFetch' + } +} diff --git a/src/pkg/github/api/delete.ts b/src/pkg/github/api/delete.ts index 9229a1a..3827a03 100644 --- a/src/pkg/github/api/delete.ts +++ b/src/pkg/github/api/delete.ts @@ -1,7 +1,7 @@ import { createOctokit } from '../octokit'; import { NextRequest, NextResponse } from 'next/server'; -export async function deleteRepo(req: NextRequest){ +export default async function deleteRepo(req: NextRequest){ try{ const octokit = await createOctokit(req) const params = await req.json() diff --git a/src/pkg/github/api/search.spec.ts b/src/pkg/github/api/search.spec.ts index 47e66a0..b572874 100644 --- a/src/pkg/github/api/search.spec.ts +++ b/src/pkg/github/api/search.spec.ts @@ -9,11 +9,16 @@ describe('search()', ()=>{ it('should returns search', async () => { const query = { - q: 'reflow', + owner: 'user:kilip' } as GitHubSearchParams const { req } = createMocks({ url: '/api/search', - body: query + body: query, + nextUrl: { + searchParams: new URLSearchParams({ + owner: 'user:kilip' + }) + } }) req.json = vi.fn().mockResolvedValue(query) const resp = { @@ -25,7 +30,9 @@ describe('search()', ()=>{ fetchMock.mock({ url: 'https://api.github.com/search/repositories', - query + query: { + q: 'user:kilip' + } }, { status: 200, body: resp @@ -37,5 +44,4 @@ describe('search()', ()=>{ expect(response).toBeInstanceOf(NextResponse) expect(json.data).toEqual(resp) }) - }) diff --git a/src/pkg/github/api/search.ts b/src/pkg/github/api/search.ts index a3bf10a..bc7dfd8 100644 --- a/src/pkg/github/api/search.ts +++ b/src/pkg/github/api/search.ts @@ -1,11 +1,53 @@ import { createOctokit } from '../octokit' import { NextRequest, NextResponse } from 'next/server' +import { GitHubEnumArchived, GitHubEnumVisibility, GitHubSearchParams } from '../types' +import { Endpoints } from '@octokit/types' +import { getGitHubProfile } from '../utils/service' -export default async function search(req: NextRequest){ +type OctokitSearchParams = Endpoints['GET /search/repositories']['parameters'] + +async function createSearchParams(params: GitHubSearchParams|any): Promise { + // setting minimal q with "user:login" + if(params.owner == ''){ + params.owner = 'user:' + (await getGitHubProfile()).login + } + + const { + owner, + keyword, + sort, + order, + per_page, + page, + visibility, + archived + } = params + + const queries: string[] = [] + + if('' !== keyword) queries.push(keyword) + if(GitHubEnumVisibility.undefined !== visibility) queries.push(visibility) + if(GitHubEnumArchived.undefined !== archived) queries.push(archived) + queries.push(owner) + + return { + q: queries.join(' ').trim(), + sort, + order, + per_page, + page + } +} + +export default async function search( + req: NextRequest +){ const octokit = await createOctokit(req) - const params = await req.json() + const params = Object.fromEntries(req.nextUrl.searchParams) + const searchParams = await createSearchParams(params) + try{ - const response = await octokit.search.repos(params) + const response = await octokit.search.repos(searchParams) return NextResponse.json(response) }catch(e: any){ return NextResponse.json({error: e}, { diff --git a/src/pkg/github/components/Dashboard.tsx b/src/pkg/github/components/Dashboard.tsx deleted file mode 100644 index 0976d40..0000000 --- a/src/pkg/github/components/Dashboard.tsx +++ /dev/null @@ -1,17 +0,0 @@ - -import React from 'react' -import GitHubProvider from '../contexts/GitHubContext' -import SearchProvider from '../contexts/SearchContext' -import Search from './Search' - -const Dashboard = () => { - return ( - - - - - - ) -} - -export default Dashboard diff --git a/src/pkg/github/components/Repositories.tsx b/src/pkg/github/components/Repositories.tsx deleted file mode 100644 index 9687252..0000000 --- a/src/pkg/github/components/Repositories.tsx +++ /dev/null @@ -1,156 +0,0 @@ -'use client' - -import { GitHubSearchItems } from '../types' -import { MdDelete, MdLock, MdPublic, MdSettings } from 'react-icons/md' -import classNames from 'classnames' -import { useRouter } from 'next/navigation' -import { useEffect, useState } from 'react' -import deleteRepo from '../hooks/delete' -import { useQueryClient } from '@tanstack/react-query' -import { useToastContext } from '@/pkg/ui/contexts/ToastContext' -import dayjs from 'dayjs' -import { useThemeContext } from '@/pkg/ui/contexts/ThemeContext' - -interface Props { - repositories: GitHubSearchItems, - loading: boolean -} - -const DeleteConfirm = ({ - repoToDelete, - handleConfirm -}:{ - repoToDelete: string, - handleConfirm: (confirmed: boolean) => void -}) => { - return ( - -
-
-

Delete {repoToDelete}

-
-
-

- Are you sure want to delete {repoToDelete} repository? -

-
-
-
- {/* if there is a button in form, it will close the modal */} - - -
-
-
-
- ) -} - -export default function Repositories({repositories}: Props) { - const { setLoading: themeLoading } = useThemeContext() - const [repoToDelete, setRepoToDelete] = useState('') - const [loading, setLoading] = useState(false) - const qc = useQueryClient() - const { success } = useToastContext() - - useEffect(() => { - themeLoading(loading) - }, [loading, themeLoading]) - - const confirmDelete = (repo: string) => { - setRepoToDelete(repo) - const el: any = document.getElementById('delete_confirm') - el?.showModal() - } - - const handleDeleteConfirmed = async (confirmed: boolean) => { - if(confirmed){ - setLoading(true) - await deleteRepo(repoToDelete) - qc.invalidateQueries({ - queryKey: ['gitHubSearch'] - }) - success('Repository '+repoToDelete+' deleted!') - setLoading(false) - } - setRepoToDelete('') - } - - return ( - <> -
- {repositories.map((repo,index) => -
- {/* header */} -
- -
-
- { repo.visibility == 'private' ? : } - {repo.visibility} -
-
-
- - {/* description */} -
-

{repo.description}

-
- - forks {repo.forks_count} - - - updated {dayjs(repo.pushed_at).format('DD-MM-YYYY')} - -
-
- - {/* action bar */} -
-
- - - -
-
- -
-
-
- )} -
- - - ) -} diff --git a/src/pkg/github/components/Search.tsx b/src/pkg/github/components/Search.tsx deleted file mode 100644 index c202dec..0000000 --- a/src/pkg/github/components/Search.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client' - -import { useSearchContext } from '../contexts/SearchContext'; -import Repositories from './Repositories'; -import Toolbar from './Toolbar'; -import { useEffect } from 'react'; -import { useSearchRepos } from '../hooks/search'; -import { useThemeContext } from '@/pkg/ui/contexts/ThemeContext'; - -export default function Search() { - const { setLoading } = useThemeContext() - const { setTotal, repositories, setRepositories } = useSearchContext() - const { data: response, isLoading, isError, error, isFetching } = useSearchRepos() - - useEffect(() => { - if(response){ - setRepositories(response.data.items) - setTotal(response.data.total_count) - } - }, [setRepositories, setTotal, response]) - - useEffect(() => { - setLoading(isFetching) - }, [isFetching, setLoading]) - - if(isError){ - return ( -
- {error.message} -
- ) - } - - return ( -
- - -
- ) -} diff --git a/src/pkg/github/components/Toolbar.tsx b/src/pkg/github/components/Toolbar.tsx deleted file mode 100644 index 77ad634..0000000 --- a/src/pkg/github/components/Toolbar.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client' - -import { ChangeEvent, KeyboardEvent, useEffect, useState } from 'react' -import { useSearchContext } from '../contexts/SearchContext' -import { MdSearch} from 'react-icons/md' -import { useGitHubContext } from '../contexts/GitHubContext' -import Pagination from '@/pkg/ui/components/Pagination' - -interface Props { - loading: boolean -} - -export default function Toolbar({loading}: Props) { - const { total, page, setPage, per_page, setKeyword } = useSearchContext() - const { profile } = useGitHubContext() - const [numPage, setNumPage] = useState(1) - const [keyword, setKeyWord] = useState('') - - useEffect(() => { - setNumPage(Math.ceil(total/per_page)) - }, [total, setNumPage, per_page, numPage, page]) - - const handleKeyDown = (e: KeyboardEvent) => { - if(e.key === 'Enter'){ - search() - } - } - - const search = () => { - setKeyword(keyword) - setPage(1) - } - - const handleKeyWordChange = (e: ChangeEvent) => { - const value = e.target.value - setKeyWord(value) - } - - const onPageChanged = (page: number) => { - setPage(page) - } - - return ( -
-
-
-
- - -
-
-
- - -
- ) -} diff --git a/src/pkg/github/context/SearchContext.tsx b/src/pkg/github/context/SearchContext.tsx new file mode 100644 index 0000000..5eb1a10 --- /dev/null +++ b/src/pkg/github/context/SearchContext.tsx @@ -0,0 +1,100 @@ +'use client' +import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react'; +import { GitHubEnumArchived, GitHubEnumSortOrder, GitHubEnumVisibility, GitHubSearchContextProps, GitHubSearchParams, GitHubSortType } from '../types'; +import { GitHub } from '../GitHub'; + +const GitHubSearchContext = createContext(undefined) + +function getDefaults(): GitHubSearchParams{ + const defaults = { + keyword: '', + owner: '', + sort: (GitHub.search.sort as GitHubSortType ), + order: GitHub.search.order, + per_page: GitHub.search.perPage, + page: 1, + visibility: GitHubEnumVisibility.undefined, + archived: GitHubEnumArchived.undefined + } + + if(typeof window !== 'undefined'){ + const json = localStorage.getItem(GitHub.search.cacheKey) + if(json){ + Object.assign(defaults, { + ...JSON.parse(json) + }) + } + } + + return defaults +} + +export function GitHubSearchProvider({children}: PropsWithChildren) { + const defaults = getDefaults() + const [keyword, setKeyword] = useState(defaults.keyword) + const [owner, setOwner] = useState(defaults.owner) + const [sort, setSort] = useState(defaults.sort) + const [order, setOrder] = useState(defaults.order) + const [page, setPage] = useState(defaults.page) + const [per_page, setPerPage] = useState(defaults.per_page) + const [visibility, setVisibility] = useState(defaults.visibility) + const [archived, setArchived] = useState(defaults.archived) + const [total, setTotal] = useState(0) + const [params, setParams] = useState(defaults) + + useEffect(() => { + const vals = { + keyword, + owner, + sort, + order, + per_page, + page, + visibility, + archived + } + setParams(vals) + if(typeof window !== 'undefined'){ + localStorage.setItem(GitHub.search.cacheKey, JSON.stringify(vals)) + } + }, [keyword, owner, sort, order, per_page, page, visibility, archived]) + + return ( + + {children} + + ) +} + +export function useGitHubSearchContext(): GitHubSearchContextProps { + const context = useContext(GitHubSearchContext) as GitHubSearchContextProps | undefined + + if(!context){ + throw new Error( + 'useGitHubSearchContext must be used within GitHubSearchProvider' + ) + } + + return context +} diff --git a/src/pkg/github/contexts/GitHubContext.tsx b/src/pkg/github/contexts/GitHubContext.tsx deleted file mode 100644 index 342c76f..0000000 --- a/src/pkg/github/contexts/GitHubContext.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client' - -import { PropsWithChildren, createContext, useContext, useEffect } from 'react' -import { GitHubContextProps, GitHubUser } from '../types' -import { useSession } from 'next-auth/react' -import { useThemeContext } from '@/pkg/ui/contexts/ThemeContext' -import Loading from '@/pkg/ui/components/Loading' - -const GitHubContext = createContext(undefined) - -export default function GitHubProvider({children}:PropsWithChildren>) { - const { setLoading } = useThemeContext() - const { data: session, status} = useSession() - const profile = session?.profile as GitHubUser - - useEffect(() => { - if(status=='loading'){ - setLoading(true) - }else{ - setLoading(false) - } - }, [status, setLoading]) - - return ( - - {children} - - ) -} - -export function useGitHubContext(): GitHubContextProps { - const context = useContext(GitHubContext) as GitHubContextProps | undefined - - if(!context){ - throw Error( - 'useGitHubContext should be used within GitHubContextProvider' - ) - } - - return context -} diff --git a/src/pkg/github/contexts/SearchContext.tsx b/src/pkg/github/contexts/SearchContext.tsx deleted file mode 100644 index de28e1d..0000000 --- a/src/pkg/github/contexts/SearchContext.tsx +++ /dev/null @@ -1,98 +0,0 @@ -'use client' -import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react'; -import { GitHubSearchContextProps, GitHubSearchItems, GitHubSearchParams } from '../types'; -import { useGitHubContext } from './GitHubContext'; - -const SearchContext = createContext(undefined) - -export default function SearchProvider({children}: PropsWithChildren) { - const test = typeof localStorage !== 'undefined' ? localStorage.getItem('gitHubSearchContext'):undefined - let existing = undefined - if(test){ - existing = JSON.parse(test) - } - - const { profile } = useGitHubContext() - - const [sort, setSort] = useState(existing? existing.sort : 'name') - const [order, setOrder] = useState(existing ? existing.order : 'asc') - const [per_page, setPerPage] = useState(existing ? existing.per_page : 6) - const [page, setPage] = useState(existing ? existing.page : 1) - const [keyword, setKeyword] = useState(existing ? existing.keyword : '') - const [owner, setOwner] = useState(existing ? existing.owner : 'user:'+profile?.login) - const [total, setTotal] = useState(0) - const [repositories, setRepositories] = useState([]) - const [initialized, setInitialized] = useState(false) - - - const getSearchParams = (): GitHubSearchParams => { - const keywords = [] - if(keyword != ''){ - keywords.push(keyword) - } - keywords.push(owner) - const params = { - q: keywords.join(' '), - per_page, - page - } - - if(sort) Object.assign(params, { sort }) - if(order) Object.assign(params, {order}) - - return params - } - - useEffect(() => { - if(typeof localStorage !== 'undefined'){ - localStorage.setItem('gitHubSearchContext', JSON.stringify({ - keyword, - owner, - sort, - order, - per_page, - page - })) - } - }, [keyword, owner,sort,order, per_page,page]) - - return ( - - {children} - - ) -} - -export function useSearchContext(): GitHubSearchContextProps { - const context = useContext(SearchContext) as GitHubSearchContextProps | undefined - - if(!context){ - throw Error( - 'useSearchContext should be used within SearchProvider' - ) - } - - return context -} diff --git a/src/pkg/github/hooks/delete.ts b/src/pkg/github/hooks/delete.ts deleted file mode 100644 index ec06f45..0000000 --- a/src/pkg/github/hooks/delete.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useMutation } from '@tanstack/react-query' -import { GitHubDeleteRepoResponse } from '../types' - -export default async function deleteRepo(repoFullName: string): Promise{ - const response = await fetch('/api/github', { - method: 'DELETE', - body: JSON.stringify({ - full_name: repoFullName - }) - }) - return await response.json() -} diff --git a/src/pkg/github/hooks/search.ts b/src/pkg/github/hooks/search.ts index c93e4a2..97db3df 100644 --- a/src/pkg/github/hooks/search.ts +++ b/src/pkg/github/hooks/search.ts @@ -1,16 +1,18 @@ -import { useSearchContext } from '../contexts/SearchContext' import { useQuery } from '@tanstack/react-query' +import { useGitHubSearchContext } from '../context/SearchContext' +import { GitHubSearchResponse } from '../types' +import { GitHub } from '../GitHub' export const useSearchRepos = () => { - const { getSearchParams } = useSearchContext() - const params = getSearchParams() + const { queryParams: params } = useGitHubSearchContext() + const result = useQuery({ - queryKey: ['gitHubSearch', { params }], - queryFn: async({queryKey}: any) => { + queryKey: [GitHub.search.queryKey, { params }], + queryFn: async({queryKey}: any): Promise => { const [_key, { params }] = queryKey - const response = await fetch('/api/github/search', { - method: 'POST', - body: JSON.stringify(params) + const searchParams = new URLSearchParams(params) + const response = await fetch('/api/github/repo?'+searchParams, { + method: 'GET', }) return await response.json() } diff --git a/src/pkg/github/hooks/update.ts b/src/pkg/github/hooks/update.ts deleted file mode 100644 index 4ba1b44..0000000 --- a/src/pkg/github/hooks/update.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { GitHubPatchRepoResponse } from '../types' - -export default async function update(owner: string, repo: string, payload: any): Promise{ - const response = await fetch('/api/github', { - method: 'PATCH', - body: JSON.stringify({ - owner, - repo, - ...payload - }) - }) - return await response.json() -} diff --git a/src/pkg/github/pages/DashboardPage.tsx b/src/pkg/github/pages/DashboardPage.tsx index 77da38a..97c4a42 100644 --- a/src/pkg/github/pages/DashboardPage.tsx +++ b/src/pkg/github/pages/DashboardPage.tsx @@ -1,8 +1,10 @@ +import { GitHubSearchProvider } from '../context/SearchContext'; +import Search from '../views/Search'; -export default function DashboardPage() { +export default async function DashboardPage() { return ( -
-

GitHub Dashboard

-
+ + + ) } diff --git a/src/pkg/github/types.ts b/src/pkg/github/types.ts index 4ea28e7..09a9a9a 100644 --- a/src/pkg/github/types.ts +++ b/src/pkg/github/types.ts @@ -1,44 +1,58 @@ import { Endpoints } from '@octokit/types' +import { components as OctokitComponents } from '@octokit/openapi-types' export type GitHubUser = Endpoints['GET /user']['response']['data'] -export type GitHubSearchParams = Endpoints['GET /search/repositories']['parameters'] export type GitHubSearchResponse = Endpoints['GET /search/repositories']['response'] export type GitHubSearchItems = GitHubSearchResponse['data']['items'] +export type GitHubSearchItem = OctokitComponents['schemas']['repo-search-result-item'] -export type GitHubContextProps = { - profile: GitHubUser, +export enum GitHubEnumSortOrder { + asc = 'asc', + desc = 'desc' } -export type GitHubSearchContextProps = { - sort?: string - order?: string - per_page: number - page: number - total: number - repositories: GitHubSearchItems - initialized: boolean +export enum GitHubEnumVisibility { + undefined = 'undefined', + public = 'is:public', + private = 'is:private' +} + +export enum GitHubEnumArchived { + undefined = 'undefined', + true = 'archived:true', + false = 'archived:false' +} + +export type GitHubSortType = "stars" | "forks" | "help-wanted-issues" | "updated" | undefined + +export type GitHubSearchParams = { keyword: string owner: string - setSort: (sort: any) => void - setOrder: (order: any) => void - setPerPage: (perPage: number) => void - setPage: (page: number) => void - setTotal: (total: number) => void - setRepositories: (items: GitHubSearchItems) => void - getSearchParams: () => GitHubSearchParams - setInitialized: (initialized: boolean) => void - setKeyword: (keyword: string) => void - setOwner: (owner:string) => void + sort: GitHubSortType + order: GitHubEnumSortOrder + page: number + per_page: number + visibility: GitHubEnumVisibility + archived: GitHubEnumArchived } -export type GitHubGetRepoParams = Endpoints['GET /repos/{owner}/{repo}']['parameters'] -export type GitHubGetRepoResponse = Endpoints['GET /repos/{owner}/{repo}']['response'] -export type GitHubRepo = GitHubGetRepoResponse['data'] +export type GitHubSearchContextProps = { + total: number + queryParams: GitHubSearchParams + setKeyword: (newVal: string) => void + setOwner: (newVal: string) => void + setSort: (newVal: string) => void + setOrder: (newVal: GitHubEnumSortOrder) => void + setPerPage: (newVal: number) => void + setPage: (newVal: number) => void + setVisibility: (newVal: GitHubEnumVisibility) => void + setArchived: (newVal: GitHubEnumArchived) => void + setTotal: (newVal: number) => void +} &GitHubSearchParams -export type GitHubDeleteRepoResponse = Endpoints['DELETE /repos/{owner}/{repo}']['response'] -export type GitHubPatchRepoEndpoints = Endpoints['PATCH /repos/{owner}/{repo}'] -export type GitHubPatchRepoParams = GitHubPatchRepoEndpoints['parameters'] -export type GitHubPatchRepoResponse = GitHubPatchRepoEndpoints['response'] +export type GitHubDeleteRepoResponse = Endpoints['DELETE /repos/{owner}/{repo}']['response'] +export type GitHubPatchRepoParams = Endpoints['PATCH /repos/{owner}/{repo}']['parameters'] +export type GitHubPatchResponse = Endpoints['PATCH /repos/{owner}/{repo}']['response'] diff --git a/src/pkg/github/utils/service.ts b/src/pkg/github/utils/service.ts new file mode 100644 index 0000000..d255270 --- /dev/null +++ b/src/pkg/github/utils/service.ts @@ -0,0 +1,44 @@ +import { getServerSession } from 'next-auth' +import { GitHubPatchRepoParams, GitHubPatchResponse, GitHubUser } from '../types' +import { AuthOptions } from '@/pkg/auth/options' +import { GitHubDeleteRepoResponse } from '../types' +import { requestError } from '@/pkg/utils/error' +import { QueryClient } from '@tanstack/react-query' + +export async function getGitHubProfile(): Promise { + const session = await getServerSession(AuthOptions) + return session?.profile as GitHubUser +} + +export async function remove(repoFullName: string): Promise{ + const response = await fetch('/api/github/repo', { + method: 'DELETE', + body: JSON.stringify({ + full_name: repoFullName + }) + }) + return await response.json() +} + +export async function patch(payload: GitHubPatchRepoParams): Promise{ + const response = await fetch('/api/github/repo', { + method: 'PATCH', + body: JSON.stringify(payload), + headers: { + 'Accept': 'application/json' + } + }) + if(response.ok){ + return await response.json() + } + + throw requestError(response) +} + +export async function invalidateQueries(queryKey: string){ + const qc = new QueryClient() + + qc.invalidateQueries({ + queryKey: [queryKey] + }) +} diff --git a/src/pkg/github/views/Search.tsx b/src/pkg/github/views/Search.tsx new file mode 100644 index 0000000..8cc8e34 --- /dev/null +++ b/src/pkg/github/views/Search.tsx @@ -0,0 +1,34 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useSearchRepos } from '../hooks/search' +import { useThemeContext } from '@/pkg/ui/contexts/ThemeContext' +import { GitHubSearchItems } from '../types' +import SearchToolbar from './SearchToolbar' +import { useGitHubSearchContext } from '../context/SearchContext' +import RepoList from './repo/RepoList' + +export default function Search() { + const { setLoading } = useThemeContext() + const { data: response, isLoading } = useSearchRepos() + const { setTotal } = useGitHubSearchContext() + const [repositories, setRepositories] = useState([]) + + useEffect(() => { + setLoading(isLoading) + }, [isLoading, setLoading]) + + useEffect(() => { + if(response){ + setRepositories(response.data.items) + setTotal(response.data.total_count) + } + }, [response, setTotal]) + + return ( +
+ + +
+ ) +} diff --git a/src/pkg/github/views/SearchToolbar.tsx b/src/pkg/github/views/SearchToolbar.tsx new file mode 100644 index 0000000..2184646 --- /dev/null +++ b/src/pkg/github/views/SearchToolbar.tsx @@ -0,0 +1,191 @@ +'use client' + +import { useGitHubSearchContext } from '../context/SearchContext' +import Pagination from '@/pkg/ui/views/Pagination' +import { useThemeContext } from '@/pkg/ui/contexts/ThemeContext' +import { useState } from 'react' +import { MdSearch } from 'react-icons/md' +import { GitHubEnumArchived, GitHubEnumSortOrder, GitHubEnumVisibility } from '../types' + +export default function SearchToolbar() { + const { + setPage, + page, + per_page, + total, + keyword, + setKeyword, + visibility, + setVisibility, + archived, + setArchived, + sort, + setSort, + order, + setOrder + } = useGitHubSearchContext() + + const { loading } = useThemeContext() + const [search, setSearch] = useState(keyword) + + const onPageChanged = (newPage: number) => { + setPage(newPage) + } + + const handleSearch = () => { + setKeyword(search) + setPage(1) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if(e.key == 'Enter'){ + handleSearch() + } + } + + const handleChange = (key:string, newValue: string) => { + if(key==='visibility') setVisibility(newValue as GitHubEnumVisibility) + if(key==='archived') setArchived(newValue as GitHubEnumArchived) + if(key==='sort') setSort(newValue) + if(key==='order') setOrder(newValue as GitHubEnumSortOrder) + + setPage(1) + } + + return ( +
+ {/* filters */} +
+ + {/* search */} +
+ setSearch(e.target.value)} + type='text' + className='input input-bordered input-sm join-item focus:outline-none' + placeholder='type here to search' + accessKey='s' + onKeyDown={(e) => handleKeyDown(e)} + /> + +
+ + {/* visibility */} +
+ + +
+ + {/* archived */} +
+ + +
+ + {/* sort */} +
+ + +
+ + {/* order */} +
+ + +
+
+ + {/* navigation */} +
+ +
+
+ ) +} diff --git a/src/pkg/github/views/repo/RepoCard.tsx b/src/pkg/github/views/repo/RepoCard.tsx new file mode 100644 index 0000000..b236710 --- /dev/null +++ b/src/pkg/github/views/repo/RepoCard.tsx @@ -0,0 +1,189 @@ +'use client' +import { Button } from 'react-daisyui' +import { MdArchive, MdDeleteForever } from 'react-icons/md' +import classNames from 'classnames' +import { useRef, useState } from 'react' +import InlineConfirmation from '@/pkg/ui/components/InlineConfirmation' +import { invalidateQueries, patch, remove } from '../../utils/service' +import { useQueryClient } from '@tanstack/react-query' +import { GitHub } from '../../GitHub' +import { useToastContext } from '@/pkg/ui/contexts/ToastContext' +import { GitHubSearchItem } from '../../types' +import { useGitHubSearchContext } from '../../context/SearchContext' + +type Props = { + repo: GitHubSearchItem +} + +enum ConfirmTypeEnum { + archive = 'archive', + delete = 'delete' +} + +type ConfirmArgs = { + type: ConfirmTypeEnum +} + +export default function RepoCard({ + repo, +}: Props) { + + const deleteRef = useRef(null) + const archiveRef = useRef(null) + const [loading, setLoading] = useState(false) + const [timeoutId, setTimeoutId] = useState(null) + const { success, error } = useToastContext() + const { queryParams } = useGitHubSearchContext() + const qc = useQueryClient() + + const showConfirm = (type: ConfirmTypeEnum) => { + const ref = ConfirmTypeEnum.delete == type ? deleteRef:archiveRef + ref.current?.classList.remove('hidden') + const timeout = setTimeout(() => { + ref.current?.classList.add('hidden') + }, 10000) + + setTimeoutId(timeout) + } + + const handleConfirmation = async(confirmed: boolean, args: ConfirmArgs ) => { + const {type} = args + const ref = ConfirmTypeEnum.delete == args.type ? deleteRef:archiveRef + clearTimeout(timeoutId) + + if(ConfirmTypeEnum.delete === type && confirmed){ + setLoading(true) + try{ + await remove(repo.full_name) + success(`Repository ${repo.full_name} deleted successfully!`) + await qc.invalidateQueries({ + queryKey: [GitHub.search.queryKey] + }) + }catch(e: any){ + console.log(e.message) + error(e.message) + } + setLoading(false) + } + else if(ConfirmTypeEnum.archive === type && confirmed){ + setLoading(true) + try{ + await patch({ + owner: repo.owner?.login as string, + repo: repo.name, + archived: !repo.archived + }) + await qc.invalidateQueries({ + queryKey: [GitHub.search.queryKey, { params: queryParams}] + }) + success(`Repository ${repo.full_name} ${!repo.archived ? 'archived':'unarchived'} successfully!`) + }catch(e: any){ + error(e.message) + } + + setLoading(false) + } + ref.current?.classList.add('hidden') + } + + return ( +
+ + {/* header */} +
+ + {repo.full_name} + + +
+ archived +
+
+
+ + {/* description */} +
+

{repo.description}

+
+ + {/* action */} +
+ + +
+ + {/* archive confirmation */} + + refId={archiveRef} + title={`${repo.archived ? 'Unarchiving' : 'Archiving'} confirmation`} + handler={handleConfirmation} + args={{ type: ConfirmTypeEnum.archive }} + style='warning' + > +

+ Are you sure want to {repo.archived ? 'unarchiving':'archiving'} this repository ?
+ {repo.full_name} +

+ + + refId={deleteRef} + title='Delete confirmation' + handler={handleConfirmation} + args={{ type: ConfirmTypeEnum.delete }} + style='error' + > +

+ Are you sure want to delete this repository ?
+ {repo.full_name} +

+ +
+
+
+ +
+
+ ) +} diff --git a/src/pkg/github/views/repo/RepoList.tsx b/src/pkg/github/views/repo/RepoList.tsx new file mode 100644 index 0000000..4c65404 --- /dev/null +++ b/src/pkg/github/views/repo/RepoList.tsx @@ -0,0 +1,20 @@ +'use client' +import { GitHubSearchItems } from '../../types' +import RepoCard from './RepoCard' + +type Props = { + repositories: GitHubSearchItems +} + +export default function RepoList({repositories}: Props) { + return ( +
+ {repositories.map((repo) => ( + + ))} +
+ ) +} diff --git a/src/pkg/ui/Providers.tsx b/src/pkg/ui/Providers.tsx new file mode 100644 index 0000000..42a8586 --- /dev/null +++ b/src/pkg/ui/Providers.tsx @@ -0,0 +1,19 @@ +import { PropsWithChildren } from 'react'; +import AuthProvider from '../auth/context/AuthContext'; +import ComposeProviders from './ComposeProviders'; +import ReactQueryProvider from './contexts/ReactQueryContext'; +import ThemeProvider from './contexts/ThemeContext'; +import ToastProvider from './contexts/ToastContext'; + +export default function Providers({children}: PropsWithChildren) { + return ( + + {children} + + ) +} diff --git a/src/pkg/ui/components/InlineConfirmation.tsx b/src/pkg/ui/components/InlineConfirmation.tsx new file mode 100644 index 0000000..154bf92 --- /dev/null +++ b/src/pkg/ui/components/InlineConfirmation.tsx @@ -0,0 +1,48 @@ +import classNames from 'classnames' +import { PropsWithChildren, RefObject, useCallback } from 'react' + +type Props = { + refId: RefObject + title: string + args: ArgsT + style: string + handler: (confirmation: boolean, args: ArgsT) => void +}&PropsWithChildren + +export default function InlineConfirmation({ + refId, + title, + children, + handler, + style, + args +}: Props) { + + const handleConfirmation = useCallback((confirmation: boolean) => { + handler(confirmation, args) + }, [handler, args]) + + return ( +
+

+ {title} +

+
+ {children} +
+
+ + +
+
+ ) +} diff --git a/src/pkg/ui/contexts/ThemeContext.tsx b/src/pkg/ui/contexts/ThemeContext.tsx index b85df44..2e95301 100644 --- a/src/pkg/ui/contexts/ThemeContext.tsx +++ b/src/pkg/ui/contexts/ThemeContext.tsx @@ -4,10 +4,10 @@ import { createContext, useContext, useReducer, useState } from 'react' import { ThemeAction, ThemeActionType, ThemeContextProps, ThemeState, ToastState } from '../types' import { PropsWithChildren } from 'react' import ToastProvider from './ToastContext' -import Layout from '../components/Layout' +import Layout from '../views/Layout' import AuthProvider from '@/pkg/auth/context/AuthContext' import ReactQueryProvider from './ReactQueryContext' -import LoadingOverlay from '../components/LoadingOverlay' +import LoadingOverlay from '../views/LoadingOverlay' function themeReducer(state: ThemeState, action: ThemeAction){ const {type, payload} = action @@ -31,9 +31,7 @@ export default function ThemeProvider({children}: PropsWithChildren) { loading, setLoading }}> - - {children} - + {children} ) diff --git a/src/pkg/ui/contexts/ToastContext.tsx b/src/pkg/ui/contexts/ToastContext.tsx index 946957d..643f29d 100644 --- a/src/pkg/ui/contexts/ToastContext.tsx +++ b/src/pkg/ui/contexts/ToastContext.tsx @@ -2,7 +2,7 @@ import { PropsWithChildren, createContext, useContext, useReducer } from 'react'; import { Toast, ToastAction, ToastActionType, ToastContextProps, ToastState, ToastType } from '../types'; -import ToastContainer from '../components/ToastContainer'; +import ToastContainer from '../views/ToastContainer'; function toastReducer(state: ToastState, action: ToastAction){ const { type, payload } = action diff --git a/src/pkg/ui/components/Layout.spec.tsx b/src/pkg/ui/views/Layout.spec.tsx similarity index 100% rename from src/pkg/ui/components/Layout.spec.tsx rename to src/pkg/ui/views/Layout.spec.tsx diff --git a/src/pkg/ui/components/Layout.tsx b/src/pkg/ui/views/Layout.tsx similarity index 100% rename from src/pkg/ui/components/Layout.tsx rename to src/pkg/ui/views/Layout.tsx diff --git a/src/pkg/ui/components/LayoutDefault.tsx b/src/pkg/ui/views/LayoutDefault.tsx similarity index 100% rename from src/pkg/ui/components/LayoutDefault.tsx rename to src/pkg/ui/views/LayoutDefault.tsx diff --git a/src/pkg/ui/components/LayoutFull.tsx b/src/pkg/ui/views/LayoutFull.tsx similarity index 100% rename from src/pkg/ui/components/LayoutFull.tsx rename to src/pkg/ui/views/LayoutFull.tsx diff --git a/src/pkg/ui/components/Loading.tsx b/src/pkg/ui/views/Loading.tsx similarity index 100% rename from src/pkg/ui/components/Loading.tsx rename to src/pkg/ui/views/Loading.tsx diff --git a/src/pkg/ui/components/LoadingOverlay.tsx b/src/pkg/ui/views/LoadingOverlay.tsx similarity index 100% rename from src/pkg/ui/components/LoadingOverlay.tsx rename to src/pkg/ui/views/LoadingOverlay.tsx diff --git a/src/pkg/ui/components/Navbar.tsx b/src/pkg/ui/views/Navbar.tsx similarity index 98% rename from src/pkg/ui/components/Navbar.tsx rename to src/pkg/ui/views/Navbar.tsx index dc79ddf..495580a 100644 --- a/src/pkg/ui/components/Navbar.tsx +++ b/src/pkg/ui/views/Navbar.tsx @@ -1,3 +1,4 @@ +'use client' import classNames from 'classnames'; import { signOut } from 'next-auth/react'; import Image from 'next/image'; diff --git a/src/pkg/ui/components/Pagination.tsx b/src/pkg/ui/views/Pagination.tsx similarity index 100% rename from src/pkg/ui/components/Pagination.tsx rename to src/pkg/ui/views/Pagination.tsx diff --git a/src/pkg/ui/components/ToastContainer.tsx b/src/pkg/ui/views/ToastContainer.tsx similarity index 100% rename from src/pkg/ui/components/ToastContainer.tsx rename to src/pkg/ui/views/ToastContainer.tsx diff --git a/src/pkg/utils/error.ts b/src/pkg/utils/error.ts new file mode 100644 index 0000000..2154d56 --- /dev/null +++ b/src/pkg/utils/error.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; + +/** + * @throws Error + */ +export function invalidContextUse(context: string, provider: string): Error { + return new Error( + `${context} must be used within ${provider}` + ) +} + +export function requestError(response: Response) { + return new Error ( + `${response.status} ${response.statusText}` + ) +} diff --git a/src/pkg/utils/fetch.spec.ts b/src/pkg/utils/fetch.spec.ts new file mode 100644 index 0000000..b82c913 --- /dev/null +++ b/src/pkg/utils/fetch.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { api } from './fetch' +import fetchMock from 'fetch-mock' + +describe('fetch()', () => { + it('should fetch request', async() => { + fetchMock + .get('http://localhost:3000/some/api', { + status: 200, + body: {hello: 'world'} + }) + + const data = await api('/some/api', { + method: 'GET' + }) + + expect(data).toEqual({hello: 'world'}) + + fetchMock.reset() + }) + + it('throws FetchError during failed response', async() => { + fetchMock + .get('http://localhost:3000/test', () => { + return new Response(JSON.stringify({hello: 'world'}), { + status: 500, + statusText: 'Server down', + }) + }) + + expect(api('/test')).rejects.toThrowError() + + fetchMock.reset() + }) +}) diff --git a/src/pkg/utils/fetch.ts b/src/pkg/utils/fetch.ts new file mode 100644 index 0000000..17a68e8 --- /dev/null +++ b/src/pkg/utils/fetch.ts @@ -0,0 +1,24 @@ +const BASE_URL = process.env.NEXTAUTH_ORIGIN || 'http://localhost:3000' + +export interface FetchError { + message: string + status: number + data: object +} + +export async function api(url: string, init?: RequestInit): Promise{ + const fullUrl = new URL(`${BASE_URL}${url}`) + const resp = await fetch(fullUrl, init) + const text = await resp.text() + const json = await JSON.parse(text) + + if(resp.ok){ + return json as T + } + + throw { + message: resp.statusText, + status: resp.status, + data: json + } as FetchError +} diff --git a/test/queryClientWrapper.tsx b/test/queryClientWrapper.tsx new file mode 100644 index 0000000..98e22f0 --- /dev/null +++ b/test/queryClientWrapper.tsx @@ -0,0 +1,16 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { PropsWithChildren } from 'react' + +const qc = new QueryClient({ + defaultOptions: { + queries: { + retry: false + } + } +}) + +export const wrapper = ({children}: PropsWithChildren) => ( + + {children} + +) diff --git a/tsconfig.json b/tsconfig.json index e59724b..b50c64b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,9 +19,10 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "test/*": ["./test/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".private/Dashboard.tsx", ".private/Repositories.tsx", ".private/Search.tsx", ".private/Toolbar.tsx"], "exclude": ["node_modules"] } diff --git a/vitest.config.ts b/vitest.config.ts index 31f0c6c..4435f3e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,8 @@ export default defineConfig({ }, resolve: { alias: { - '@': './src' + '@': './src', + 'test': './test' } } -}) \ No newline at end of file +})