diff --git a/apps/examples/nextjs/.gitignore b/apps/examples/nextjs/.gitignore new file mode 100644 index 0000000..8f322f0 --- /dev/null +++ b/apps/examples/nextjs/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/examples/nextjs/README.md b/apps/examples/nextjs/README.md new file mode 100644 index 0000000..f4da3c4 --- /dev/null +++ b/apps/examples/nextjs/README.md @@ -0,0 +1,34 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/apps/examples/nextjs/next.config.js b/apps/examples/nextjs/next.config.js new file mode 100644 index 0000000..fb5cc8b --- /dev/null +++ b/apps/examples/nextjs/next.config.js @@ -0,0 +1,15 @@ +/** @type {import('next').NextConfig} */ +const webpack = require("webpack"); + +const nextConfig = { + webpack: (config, { isServer, nextRuntime }) => { + // Avoid AWS SDK Node.js require issue + if (isServer && nextRuntime === "nodejs") + config.plugins.push( + new webpack.IgnorePlugin({ resourceRegExp: /^aws-crt|signature-v4-crt$/ }) + ); + return config; + }, +} + +module.exports = nextConfig diff --git a/apps/examples/nextjs/package.json b/apps/examples/nextjs/package.json new file mode 100644 index 0000000..1f23326 --- /dev/null +++ b/apps/examples/nextjs/package.json @@ -0,0 +1,27 @@ +{ + "name": "nextjs-example", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@types/node": "20.3.3", + "@types/react": "18.2.14", + "@types/react-dom": "18.2.6", + "autoprefixer": "10.4.14", + "next": "13.4.8", + "postcss": "8.4.24", + "react": "18.2.0", + "react-dom": "18.2.0", + "tailwindcss": "3.3.2", + "typescript": "^5.1.6", + "@server/core": "workspace:*", + "@providers/s3": "workspace:*", + "@adapters/interface": "workspace:*", + "shared-types": "workspace:*" + } +} diff --git a/apps/examples/nextjs/postcss.config.js b/apps/examples/nextjs/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/apps/examples/nextjs/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/examples/nextjs/public/next.svg b/apps/examples/nextjs/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/apps/examples/nextjs/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/examples/nextjs/public/vercel.svg b/apps/examples/nextjs/public/vercel.svg new file mode 100644 index 0000000..d2f8422 --- /dev/null +++ b/apps/examples/nextjs/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/examples/nextjs/src/app/StepSection.tsx b/apps/examples/nextjs/src/app/StepSection.tsx new file mode 100644 index 0000000..a80fafa --- /dev/null +++ b/apps/examples/nextjs/src/app/StepSection.tsx @@ -0,0 +1,51 @@ + import {ReactNode, useMemo} from "react"; + + +interface NumberComponentProps { + number: number + done?: boolean +} + +const NumberComponent = ({ number, done }: NumberComponentProps) => { + return ( +
+ {done ? '✓' : number} +
+ ) +} + +interface Props { + number: number + active: boolean + done?: boolean + children: (disabled: boolean) => ReactNode +} + +export default function StepSection ({ children, number, active, done }: Props){ + const disabled = useMemo(() => { + return !active + }, [active]) + + return ( +
+
+ +
+
+ {children(disabled)} +
+
+ ) +} diff --git a/apps/examples/nextjs/src/app/api/README.md b/apps/examples/nextjs/src/app/api/README.md new file mode 100644 index 0000000..e9ac9d6 --- /dev/null +++ b/apps/examples/nextjs/src/app/api/README.md @@ -0,0 +1,9 @@ +### API Routes List +- POST /api/users/[id]/images + - signs upload url and returns a **signed url** + **confirmation token** +- GET /api/users/[id]/images/[imageId] + - gets image url **status** and **image variants** if they exist +- DELETE /api/users/[id]/images/[imageId] + - checks if file exists in db, if yes it deletes the file from db and storage provider as well. +- POST /api/users/[id]/images/[imageId]/confirm + - validates **confirm token**, checks if image **exists** in the storage provider and finally updates the file status to **uploaded**. \ No newline at end of file diff --git a/apps/examples/nextjs/src/app/api/users/[id]/images/[imageId]/confirm/route.ts b/apps/examples/nextjs/src/app/api/users/[id]/images/[imageId]/confirm/route.ts new file mode 100644 index 0000000..533b17c --- /dev/null +++ b/apps/examples/nextjs/src/app/api/users/[id]/images/[imageId]/confirm/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server' +import { uploadWizard } from '../../../../../../upload-wizard' + +export async function POST( + request: NextRequest, + { params }: { params: { id: string; imageId: string } } +) { + // TODO: validate confirm Token + await uploadWizard.confirmUpload(params.imageId, 'confirmToken') + + return NextResponse.json({}) +} diff --git a/apps/examples/nextjs/src/app/api/users/[id]/images/[imageId]/route.ts b/apps/examples/nextjs/src/app/api/users/[id]/images/[imageId]/route.ts new file mode 100644 index 0000000..27cb620 --- /dev/null +++ b/apps/examples/nextjs/src/app/api/users/[id]/images/[imageId]/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server' +import { uploadWizard } from '../../../../../upload-wizard' + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string; imageId: string } } +) { + // TODO: delete image + await uploadWizard.delete(params.imageId) + + return NextResponse.json({}) +} + +export async function GET( + request: NextRequest, + { params }: { params: { id: string; imageId: string } } +) { + // TODO: get image + const data = await uploadWizard.getData(params.imageId) + + return NextResponse.json(data) +} diff --git a/apps/examples/nextjs/src/app/api/users/[id]/images/route.ts b/apps/examples/nextjs/src/app/api/users/[id]/images/route.ts new file mode 100644 index 0000000..15d8d31 --- /dev/null +++ b/apps/examples/nextjs/src/app/api/users/[id]/images/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server' +import { uploadWizard } from '../../../../upload-wizard' + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + let response + try { + response = await uploadWizard.signedUploadUrl() + } catch (e) { + console.log('e', e) + return new Response('Internal server error', { + status: 500, + }) + } + + return NextResponse.json(response) +} diff --git a/apps/examples/nextjs/src/app/favicon.ico b/apps/examples/nextjs/src/app/favicon.ico new file mode 100644 index 0000000..4570eb8 Binary files /dev/null and b/apps/examples/nextjs/src/app/favicon.ico differ diff --git a/apps/examples/nextjs/src/app/globals.css b/apps/examples/nextjs/src/app/globals.css new file mode 100644 index 0000000..fd81e88 --- /dev/null +++ b/apps/examples/nextjs/src/app/globals.css @@ -0,0 +1,27 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } +} + +body { + color: rgb(var(--foreground-rgb)); + background: linear-gradient( + to bottom, + transparent, + rgb(var(--background-end-rgb)) + ) + rgb(var(--background-start-rgb)); +} diff --git a/apps/examples/nextjs/src/app/layout.tsx b/apps/examples/nextjs/src/app/layout.tsx new file mode 100644 index 0000000..7ed8f04 --- /dev/null +++ b/apps/examples/nextjs/src/app/layout.tsx @@ -0,0 +1,21 @@ +import './globals.css' +import { Inter } from 'next/font/google' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata = { + title: 'Upload Wizard - Next.js Example', + description: 'Generated by create next app', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/apps/examples/nextjs/src/app/page.tsx b/apps/examples/nextjs/src/app/page.tsx new file mode 100644 index 0000000..5a6c0e1 --- /dev/null +++ b/apps/examples/nextjs/src/app/page.tsx @@ -0,0 +1,203 @@ +'use client' +import { useEffect, useRef, useState } from 'react' +import PrimaryButton from '@components/atoms/buttons/Primary' +import StepSection from './StepSection' +import { + UploadHandler, + RequestSignedURLReturnObject, +} from '../utils/upload_handler' +import { Step } from '../types/step' +import { z } from 'zod' +import { Simulate } from 'react-dom/test-utils' + +const controller = new AbortController() + +const uploadHandler = new UploadHandler(controller) + +export default function Home() { + const [data, setData] = useState<{ message: string } | null>() + + const [step, setStep] = useState(Step.CHOOSE_FILE) + + const signedURL = useRef | null>(null) + + const pickedFile = useRef(null) + + useEffect(() => () => controller.abort(), []) + + const onFilePicked = async (file: File) => { + console.log('onFilePicked', file.name) + + pickedFile.current = file + console.log('picked file done', pickedFile.current) + setStep(Step.REQUEST_UPLOAD_URL) + + try { + // const mediaFile = await uploadHandler.upload(file) + // const { url, confirmToken, id } = + // await uploadHandler.requestSignedURL() + // await uploadHandler.uploadImage(file, url) + // + // await uploadHandler.confirmUpload(id, confirmToken) + // + // const mediaFile = await uploadHandler.pollFileUntilReady(id) + // setData({ message: `File uploaded: ${mediaFile.variants}` }) + } catch (err) { + // setData({ message: `Upload aborted: ${err}` }) + } + } + + const onRequestUploadURL = async () => { + try { + signedURL.current = await uploadHandler.requestSignedURL() + console.log('signed url done', signedURL.current) + setStep(Step.UPLOAD_FILE) + } catch (e) { + console.error('Failed to request signed URL', e) + } + } + + const checkFileExists = () => { + if (pickedFile.current === null) throw new Error('No file picked') + return pickedFile.current + } + + const checkSignedURLExists = () => { + if (signedURL.current === null) throw new Error('No signed url') + return signedURL.current + } + const onUploadFile = async () => { + try { + const file = checkFileExists() + const signedUrl = checkSignedURLExists() + await uploadHandler.uploadImage(file, signedUrl.url) + console.log('uploaded file done') + setStep(Step.CONFIRM_UPLOAD) + } catch (e) { + console.error('Failed to upload file', e) + } + } + + const onConfirmUpload = async () => { + try { + const signedURL = checkSignedURLExists() + await uploadHandler.confirmUpload( + signedURL.id, + signedURL.confirmToken + ) + console.log('confirmed upload done') + setStep(Step.POLL_FILE) + } catch (e) { + console.error('Failed to confirm upload', e) + } + } + + const onPollFile = async () => { + try { + const signedURL = checkSignedURLExists() + const mediaFile = await uploadHandler.pollFileUntilReady( + signedURL.id + ) + console.log('poll file done', mediaFile) + setData({ message: `File uploaded: ${mediaFile.variants}` }) + } catch (e) { + console.error('Failed to poll file', e) + } + } + + const [done, setDone] = useState(false) + + return ( +
+
+

Upload Wizard - Next.js Example

+
+
+
+

setDone((prevState) => !prevState)} + > + Upload +

+ + + {(disabled) => ( +
+ + { + const file = e.target.files?.[0] + if (file) onFilePicked(file) + }} + /> +
+ )} +
+ + + {(disabled) => ( + + Request Upload URL + + )} + + + + {(disabled) => ( + + Upload File + + )} + + + + {(disabled) => ( + + Confirm Upload + + )} + + + + {(disabled) => ( + + Poll File + + )} + +
+
+ ) +} diff --git a/apps/examples/nextjs/src/app/upload-wizard.ts b/apps/examples/nextjs/src/app/upload-wizard.ts new file mode 100644 index 0000000..2e38382 --- /dev/null +++ b/apps/examples/nextjs/src/app/upload-wizard.ts @@ -0,0 +1,37 @@ +import { UploadWizard } from '@server/core' +import { S3Provider } from '@providers/s3' +import { DBFileProvider } from "@adapters/interface"; + +class DBProvider extends DBFileProvider { + createEntry(input: any): Promise { + return Promise.resolve(undefined) + } + + deleteEntry(fileId: string): Promise { + return Promise.resolve(undefined) + } + + updateStatus(fileId: string, status: any): Promise { + return Promise.resolve(undefined) + } + + validateConfirmToken( + fileId: string, + confirmToken: string + ): Promise { + return Promise.resolve(true) + } + + exists(fileId: string): Promise { + return Promise.resolve(true) + } +} + +export const uploadWizard = new UploadWizard({ + dbFileProvider: new DBProvider(), + storageServiceProvider: new S3Provider({ + bucketPath: 'uploads', + acl: 'bucket-owner-full-control', + optimisticFileDataResponse: true, + }), +}) diff --git a/apps/examples/nextjs/src/components/atoms/buttons/Primary.tsx b/apps/examples/nextjs/src/components/atoms/buttons/Primary.tsx new file mode 100644 index 0000000..a7196a3 --- /dev/null +++ b/apps/examples/nextjs/src/components/atoms/buttons/Primary.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import { ButtonProps } from '@components/types/generic' + +export default function PrimaryButton({ className, ...rest }: ButtonProps) { + return ( +