diff --git a/bun.lockb b/bun.lockb index 972b537..38631d4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/next.config.js b/next.config.js index 4926ae8..2bd422a 100644 --- a/next.config.js +++ b/next.config.js @@ -4,6 +4,7 @@ const nextConfig = { remotePatterns: [ { hostname: 'cdn.dribbble.com' }, { hostname: 'cdn.hashnode.com' }, + { hostname: 'media.graphassets.com' }, ], }, } diff --git a/package.json b/package.json index 93115a5..b10a7bc 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@vercel/analytics": "^1.1.1", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", + "date-fns": "^2.30.0", "eslint-config-prettier": "^9.0.0", "graphql-request": "^6.1.0", "gsap": "^3.12.2", diff --git a/src/app/works/[slug]/page.tsx b/src/app/works/[slug]/page.tsx new file mode 100644 index 0000000..d8cdf88 --- /dev/null +++ b/src/app/works/[slug]/page.tsx @@ -0,0 +1,44 @@ +import { Metadata, ResolvingMetadata } from 'next' +import { notFound } from 'next/navigation' +import { workService } from '~/services/work-service' +import { + defaultOpenGraphMetadata, + defaultTwitterMetadata, +} from '~/lib/shared-metadata' + +type Props = { + params: { + slug: string + } +} + +export async function generateMetadata( + { params }: Props, + parent: ResolvingMetadata, +): Promise { + const slug = params.slug + const meta = await workService.getMetadata(slug) + + if (!slug || !meta) { + return notFound() + } + + return { + title: `${meta.title} | Nyoman Sunima`, + description: meta.desc, + openGraph: { + ...defaultOpenGraphMetadata, + title: `${meta.title} | Nyoman Sunima`, + description: meta.desc, + }, + twitter: { + ...defaultTwitterMetadata, + title: `${meta.title} | Nyoman Sunima`, + description: meta.desc, + }, + } +} + +export default function WorkDetailPage({ params }: Props) { + return
+} diff --git a/src/app/works/components/work-list-item.tsx b/src/app/works/components/work-list-item.tsx new file mode 100644 index 0000000..b61fb99 --- /dev/null +++ b/src/app/works/components/work-list-item.tsx @@ -0,0 +1,56 @@ +import Image from 'next/image' +import Link from 'next/link' +import * as React from 'react' +import { Work } from '~/types' +import { mergeClass, parseReadableDate } from '~/utils/helpers' + +type ItemPillProps = { + children: React.ReactNode + className?: string +} + +type Props = { + work: Work +} + +function ItemPill({ children, className }: ItemPillProps) { + return ( + + {children} + + ) +} + +export function WorkListItemCard({ work }: Props) { + const workURL = `/works/${work.slug}` + const parsedDate = parseReadableDate(work.createdAt) + const tag = work.tags[0] || '' + + const Comp = work.status === 'DONE' ? 'a' : 'div' + + return ( + + + Hello + + {work.status === 'IN_PROGRESS' && ( + On Going + )} + +
+ {tag} + {parsedDate} +
+
+ +

+ {work.title} +

+
+ ) +} diff --git a/src/app/works/components/works-intro-section.tsx b/src/app/works/components/works-intro-section.tsx new file mode 100644 index 0000000..5b28982 --- /dev/null +++ b/src/app/works/components/works-intro-section.tsx @@ -0,0 +1,15 @@ +export default function WorksIntroSection() { + return ( +
+

+ Design, Development & Apps crafting works. Built using heart and mind to + solve problems. +

+
+ ) +} diff --git a/src/app/works/components/works-list-section.tsx b/src/app/works/components/works-list-section.tsx new file mode 100644 index 0000000..8297c33 --- /dev/null +++ b/src/app/works/components/works-list-section.tsx @@ -0,0 +1,95 @@ +'use client' + +import { Button } from '~/app/components/ui/button' +import { WorkListItemCard } from './work-list-item' +import { useInfiniteQuery } from '@tanstack/react-query' +import { workService } from '~/services/work-service' +import * as React from 'react' +import { useRouter, useSearchParams } from 'next/navigation' + +const types = ['Project', 'Exploration', 'Playground'] + +function WorksFilter() { + const router = useRouter() + const searchParams = useSearchParams() + const typeParam = searchParams.get('type') + + function filteringByType(type: string) { + const newParams = new URLSearchParams(searchParams.toString()) + if (newParams.get('type') === type) { + newParams.delete('type') + } else { + newParams.set('type', type) + } + + const newURL = `/works?${newParams.toString()}` + + router.push(newURL) + } + + return ( +
+
+ {types.map((type, i) => ( + + ))} +
+
+ ) +} + +function WorksList() { + const searchParams = useSearchParams() + const { data, isSuccess, isFetchingNextPage, fetchNextPage, hasNextPage } = + useInfiniteQuery({ + queryKey: ['works', 'list', { ...searchParams }], + initialPageParam: null, + queryFn: workService.getAllWorks, + getNextPageParam: (lastPage, allPages) => lastPage.pageInfo.endCursor, + }) + + return ( +
+
+ {isSuccess && + data.pages.map((group, i) => ( + + {group.works.map((work, i) => ( + + ))} + + ))} +
+ +
+ {hasNextPage && data && ( + + )} +
+
+ ) +} + +export default function WorksListSection() { + return ( +
+
+ + +
+
+ ) +} diff --git a/src/app/works/page.tsx b/src/app/works/page.tsx new file mode 100644 index 0000000..28a0097 --- /dev/null +++ b/src/app/works/page.tsx @@ -0,0 +1,31 @@ +import { Metadata } from 'next' +import { + defaultOpenGraphMetadata, + defaultTwitterMetadata, +} from '~/lib/shared-metadata' +import WorksIntroSection from './components/works-intro-section' +import WorksListSection from './components/works-list-section' + +export const metadata: Metadata = { + title: 'Design, development & indie hacking works | Nyoman Sunima', + description: 'My works and all of the playground', + openGraph: { + ...defaultOpenGraphMetadata, + title: 'Design, development & indie hacking works | Nyoman Sunima', + description: 'My works and all of the playground', + }, + twitter: { + ...defaultTwitterMetadata, + title: 'Design, development & indie hacking works | Nyoman Sunima', + description: 'My works and all of the playground', + }, +} + +export default function WorksPage() { + return ( +
+ + +
+ ) +} diff --git a/src/constants/menu.ts b/src/constants/menu.ts index 3e7af11..35ddcfb 100644 --- a/src/constants/menu.ts +++ b/src/constants/menu.ts @@ -6,7 +6,7 @@ type MenuItem = { export const sideNavMenus: MenuItem[] = [ { label: 'My Works', - link: '/projects', + link: '/works', }, { label: 'Services', @@ -20,4 +20,8 @@ export const sideNavMenus: MenuItem[] = [ label: 'Contacts', link: '/contact', }, + { + label: 'Blog', + link: '/blog', + }, ] diff --git a/src/services/work-service.ts b/src/services/work-service.ts new file mode 100644 index 0000000..410f8fd --- /dev/null +++ b/src/services/work-service.ts @@ -0,0 +1,88 @@ +import { hygraphConnection } from '~/config/hygraph' +import { PaginatedWork, Work, WorkMeta } from '~/types' + +class WorkService { + async getAllWorks({ pageParam, queryKey }): Promise { + const searchParams = queryKey[2] as URLSearchParams + let types = ['Project', 'Playground', 'Exploration'] + const typeParam = searchParams.get('type') + if (typeParam) { + types = [typeParam] + } + + const query = ` + query WorksQuery($limit: Int!, $nextCursor: String, $types: [WorkType]) { + worksConnection(first: $limit, after: $nextCursor, where: {type_in: $types}) { + edges { + node { + slug + tags + title + type + workStatus + createdAt + thumbnail { + url + } + } + } + pageInfo { + hasNextPage + pageSize + endCursor + } + } + } + ` + + const res = await hygraphConnection.request(query, { + limit: 6, + nextCursor: pageParam, + types, + }) + + const payload = { + works: res.worksConnection.edges + .map((edge) => edge.node) + .map( + (node) => + ({ + ...node, + status: node.workStatus, + thumbnail: node.thumbnail.url, + } as Work), + ), + pageInfo: res.worksConnection.pageInfo, + } + + return payload + } + + async getMetadata(slug: string): Promise { + const query = ` + query WorkMetadata($slug: String!) { + work(where: {slug: $slug}) { + title + description + thumbnail { + url + } + } + } + ` + + const res = await hygraphConnection.request(query, { + slug, + }) + + const payload: WorkMeta = { + title: res.work.title, + desc: res.work.description, + image: res.work.thumbnail.url, + } + + return payload + } +} + +export const workService = new WorkService() diff --git a/src/types/index.ts b/src/types/index.ts index 277641c..0fd1919 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,5 @@ +import { boolean } from 'zod' + export type FAQ = { question: string answer: string @@ -68,3 +70,28 @@ export type LinkBio = { publishedAt: string image?: string } + +export type Work = { + slug: string + tags: string[] + title: string + type: string + createdAt: string + thumbnail: string + status: string +} + +export type WorkMeta = { + title: string + desc: string + image: string +} + +export type PaginatedWork = { + works: Work[] + pageInfo: { + hasNextPage: boolean + pageSize: number + endCursor: string + } +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index c4b1d67..b6562dc 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,5 +1,6 @@ import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' +import * as DateUtil from 'date-fns' /** * Merge the class to combine without @@ -11,3 +12,19 @@ import { twMerge } from 'tailwind-merge' export function mergeClass(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** + * Format the date become more readbale version + * allow to read and modification the string date into more usable + * + * @param stringDate date want to prase + * @param format the date format using `date-fns` guidelines + * @returns {string} + */ +export function parseReadableDate(stringDate: string, format?: string): string { + const defaultFormat = 'MMMM, yyyy' + const date = new Date(stringDate) + const formattedDate = DateUtil.format(date, format ?? defaultFormat) + + return formattedDate || '' +}