Check the Docs on the deployed version a7san
A custom hook was created to get the user token on first render from local storage and set the token value on login and signup using jotai
util atomWithStorage
import { useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
const tokenValue = atomWithStorage<string | null>('trello-3al-daya2-token', null)
export const useTokenValue = () => {
const [token, setToken] = useAtom(tokenValue)
return {
token,
setToken
}
}
import { useTokenValue } from "./hooks/useTokenValue";
function App() {
const { token, setToken } = useTokenValue()
if (token) {
return (
// user is logged in and dashboard is rendered
)
}
return (
// user is not logged in and landing page is rendered with login and signup
<Route path="/login" element={<Login setToken={setToken} />} />
<Route path="/signup" element={<Signup setToken={setToken} />} />
);
}
Server state was handled using Apollo Client
import ReactDOM from 'react-dom/client'
import ApolloProvider from './components/ApolloProvider.tsx'
ReactDOM.createRoot(document.getElementById('root')!).render(
<ApolloProvider>
<Router>
<App />
</Router>
</ApolloProvider>
)
A custom ApolloProvider component was created to wrap the app with the apollo client and set the authorization header with the user token from local storage
useTokenValue
is imported and used to get the user token
import { useTokenValue } from '@/hooks/useTokenValue'
interface Props {
children: React.ReactNode
}
const ApolloProvider = ({children}:Props) => {
const { token } = useTokenValue()
const httpLink = createHttpLink({
uri: import.meta.env.VITE_SERVER,
})
const client = new ApolloClient({
cache: new InMemoryCache(),
link: authLink.concat(httpLink),
})
return (
<Provider client={client}>
{children}
</Provider>
)
}
export default ApolloProvider
setContext
is used to set the authorization header with the user token
import { setContext } from '@apollo/client/link/context'
import { useTokenValue } from '@/hooks/useTokenValue'
interface Props {
children: React.ReactNode
}
const ApolloProvider = ({children}:Props) => {
const { token } = useTokenValue()
const authLink = setContext((_, { headers }) => {
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : null,
}
}
})
const httpLink = createHttpLink({
uri: import.meta.env.VITE_SERVER,
})
const client = new ApolloClient({
cache: new InMemoryCache(),
link: authLink.concat(httpLink),
})
return (
<Provider client={client}>
{children}
</Provider>
)
}
export default ApolloProvider
The apollo client is created with the authLink
created and httpLink
from the server uri which is set in the .env
file according to the environment (development or production)
import {
ApolloClient,
ApolloProvider as Provider,
InMemoryCache,
createHttpLink,
} from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { useTokenValue } from '@/hooks/useTokenValue'
interface Props {
children: React.ReactNode
}
const ApolloProvider = ({children}:Props) => {
const { token } = useTokenValue()
const authLink = setContext((_, { headers }) => {
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : null,
}
}
})
const httpLink = createHttpLink({
uri: import.meta.env.VITE_SERVER,
})
const client = new ApolloClient({
cache: new InMemoryCache(),
link: authLink.concat(httpLink),
})
return (
<Provider client={client}>
{children}
</Provider>
)
}
export default ApolloProvider
VITE_SERVER=http://localhost:4000/graphql
Custom hook useRandomPhotos
is created to fetch random photos from Unsplash API returning the photos array, with loading and error state
import { useState, useEffect } from 'react'
const useRandomPhotos = () => {
const [data, setData] = useState<any>([])
const [error, setError] = useState(null)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
setIsLoading(true)
setData([])
fetch(`https://api.unsplash.com/photos/random
?client_id=${import.meta.env.VITE_ACCESS_KEY}
&count=4
&topics=minimalism,wallpapers,nature,textures-patterns`)
.then((res) => res.json())
.then((data) => {
setData(data)
setIsLoading(false)
})
.catch((error) => {
setError(error)
setIsLoading(false)
})
}, [])
return {
photos: data.map((photo) => photo.urls.regular),
error,
isLoading
}
}
export default useRandomPhotos
Selecting the topics and count from the API
Handling Errors with hardcoded data (Unsplash API has a limit of 50 requests per hour)
import { useState, useEffect } from 'react'
const dataIfError = [
{
"id": "0z-sWoAqs0c",
"urls": {
"raw": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3",
"full": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?crop=entropy&cs=srgb&fm=jpg&ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3&q=85",
"regular": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3&q=80&w=1080",
"small": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3&q=80&w=400",
"thumb": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3&q=80&w=200",
"small_s3": "https://s3.us-west-2.amazonaws.com/images.unsplash.com/small/photo-1704500355467-88351c0797c3"
},
// ...
}
]
const useRandomPhotos = () => {
const [data, setData] = useState<any>([])
const [error, setError] = useState(null)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
setIsLoading(true)
setData([])
fetch(`https://api.unsplash.com/photos/random
?client_id=${import.meta.env.VITE_ACCESS_KEY}
&count=4
&topics=minimalism,wallpapers,nature,textures-patterns`)
.then((res) => res.json())
.then((data) => {
setData(data)
setIsLoading(false)
})
.catch((error) => {
setError(error)
setIsLoading(false)
setData(dataIfError)
})
}, [])
return {
photos: data.map((photo) => photo.urls.regular),
error,
isLoading
}
}
export default useRandomPhotos
Handling background selection
import { useState, useEffect } from 'react'
const dataIfError = [
{
"id": "0z-sWoAqs0c",
"urls": {
"raw": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3",
"full": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?crop=entropy&cs=srgb&fm=jpg&ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3&q=85",
"regular": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3&q=80&w=1080",
"small": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3&q=80&w=400",
"thumb": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3&q=80&w=200",
"small_s3": "https://s3.us-west-2.amazonaws.com/images.unsplash.com/small/photo-1704500355467-88351c0797c3"
},
// ...
}
]
const useRandomPhotos = () => {
const [data, setData] = useState<any>([])
const [error, setError] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const [selectedBg, setSelectedBg] = useState('')
const handleBgChange = (bg: string) => {
setSelectedBg(bg)
}
useEffect(() => {
setIsLoading(true)
setData([])
fetch(`https://api.unsplash.com/photos/random?client_id=${import.meta.env.VITE_ACCESS_KEY}&count=4&topics=minimalism,wallpapers,nature,textures-patterns`)
.then((res) => res.json())
.then((data) => {
setData(data)
setSelectedBg(data[0].urls.regular)
setIsLoading(false)
})
.catch((error) => {
setError(error)
setIsLoading(false)
setData(dataIfError)
})
}, [])
return {
photos: data.map((photo) => photo.urls.regular),
error,
isLoading,
selectedBg,
handleBgChange
}
}
export default useRandomPhotos
Using the hook
import { useState, useEffect } from 'react'
const dataIfError = [
{
"id": "0z-sWoAqs0c",
"urls": {
"raw": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3",
"full": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?crop=entropy&cs=srgb&fm=jpg&ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3&q=85",
"regular": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3&q=80&w=1080",
"small": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3&q=80&w=400",
"thumb": "https://images.unsplash.com/photo-1704500355467-88351c0797c3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w1NDc0MTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3MDQ4OTE3NzV8&ixlib=rb-4.0.3&q=80&w=200",
"small_s3": "https://s3.us-west-2.amazonaws.com/images.unsplash.com/small/photo-1704500355467-88351c0797c3"
},
// ...
}
]
const useRandomPhotos = () => {
const [data, setData] = useState<any>([])
const [error, setError] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const [selectedBg, setSelectedBg] = useState('')
const handleBgChange = (bg: string) => {
setSelectedBg(bg)
}
useEffect(() => {
setIsLoading(true)
setData([])
fetch(`https://api.unsplash.com/photos/random?client_id=${import.meta.env.VITE_ACCESS_KEY}&count=4&topics=minimalism,wallpapers,nature,textures-patterns`)
.then((res) => res.json())
.then((data) => {
setData(data)
setSelectedBg(data[0].urls.regular)
setIsLoading(false)
})
.catch((error) => {
setError(error)
setIsLoading(false)
setData(dataIfError)
})
}, [])
return {
photos: data.map((photo) => photo.urls.regular),
error,
isLoading,
selectedBg,
handleBgChange
}
}
export default useRandomPhotos
import useRandomPhotos from "@/hooks/useRandomPhotos";
export default function AddBoard() {
const { photos, isLoading, selectedBg, handleBgChange } = useRandomPhotos()
return (
{isLoading && (
// Skeleton
)}
{ !isLoading && (
<div className="w-full flex-1 flex items-center flex-col gap-4">
<div className="relative rounded-lg h-36 w-48 overflow-hidden">
<img src={selectedBg} alt="" className="object-cover absolute w-full h-full shadow-md" />
<Placeholder />
</div>
<div className="flex gap-2">
{photos.map((bg, i) => (
<button
key={i}
className={cn('relative group rounded-md h-12 w-16 overflow-hidden transition', selectedBg === bg ? 'ring-2 ring-blue-500' : '')}
onClick={() => handleBgChange(bg)}
>
{selectedBg === bg && (
<div className="absolute z-20 inset-0 flex items-center justify-center">
<Check className="text-white/80 w-5 h-5" />
</div>
)}
<div className={cn("absolute hidden group-hover:block z-10 inset-0 bg-black/40 items-center justify-center", selectedBg === bg && 'block')} />
<img src={bg} alt="" className="object-cover absolute inset-0 w-full h-full shadow-md" />
</button>
))}
</div>
</div>
)}
)
}
mutation createBoard($title: String!, $description: String, $bg:String!){
createBoard(title: $title, description: $description, bg:$bg){
id
title
description
bg
updated_at
saved
}
}
import { useMutation } from "@apollo/client"
import {
CreateBoardDocument,
CreateBoardMutationVariables,
CreateBoardMutation,
AllBoardsDocument
} from "@/generated/graphql"
import { useNavigate } from "react-router-dom";
export default function AddBoard() {
const navigate = useNavigate()
const [createBoard] = useMutation<CreateBoardMutation, CreateBoardMutationVariables>(CreateBoardDocument, {
onCompleted(data) {
if (data.createBoard) {
navigate(`/${data.createBoard.id}`)
}
},
update: (cache, response) => {
cache.updateQuery({ query: AllBoardsDocument }, (data) => {
if (response.data?.createBoard) {
return {
allBoards: [response.data.createBoard, ...data.allBoards]
}
}
return data
})
}
})
}
when mutation is completed useNavigate
is used to redirect the user to the new board page and cache is immutably updated with the new board data using updateQuery
on a successful response
Using [Codegen](https://the-guild.dev/graphql/codegen) to generate typed queries and mutations
npm i -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-graphql-request @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo
- `@graphql-codegen/cli`
- `@graphql-codegen/typescript`
- `@graphql-codegen/typescript-graphql-request`
- `@graphql-codegen/typescript-operations`
- `@graphql-codegen/typescript-react-apollo`
schema:
- "http://localhost:4000/"
documents:
- "./src/graphql/*.graphql"
generates:
./src/generated/graphql.ts:
plugins:
- "typescript"
- "typescript-operations"
- "typescript-react-apollo"
- `schema` points to our GraphQL endpoint for fetching the API schema map.
- `documents` tells GraphQL Codegen where to look for our schema files.
- `generates` tells GraphQL Codegen where to create and store generated code.
- `plugins` Specifies what GraphQL Codegen plugins to use.
{
"scripts": {
"generate": "graphql-codegen --config graphql.config.yml",
"predev": "npm run generate"
}
}
The generate
script will run the codegen and generate the types in ./src/generated/graphql.ts
Handling optimistic updates was the most challenging part of the project, as it would feel weird when the user move or create a card/list and it disappears and reappears again after the response is received from the server.
It was achieved by passing the optimisticResponse
object to the mutation options to the useMutation
hook.
const [addList] = useMutation<AddListMutation, AddListMutationVariables>(AddListDocument,{
optimisticResponse: {
__typename: "Mutation",
addList: {
__typename: "Board",
id: id as string,
title: data?.findBoard?.title as string,
bg: data?.findBoard?.bg as string,
description: data?.findBoard?.description as string,
lists: [
...data?.findBoard?.lists as any[] || [],
{
__typename: "List",
id: `temp-${Date.now()}`,
title: listTitle,
cards: []
}
]
}
}
})
when the mutation happens the optimistic response is cached with a separate cache identifier to insure that our cached data remains accurate if the mutation fails, and eventually when the server responds with the actual resulting object the optimistic version is removed.
A temporary id
is provided to be able to identify the object which is then replaced with the actual id
from the server response.
This technique was used for most mutations in the project.
Drag and drop was implemented using react-beautiful-dnd library which is maintained and relied on by Atlassian the company behind Trello.
I highly recommend this course by Alex Reardon the creator of the library.
Displaying modal as an overlay
When a modal is opened, the previous location is passed to `` instead of using the current location by default.
export default function App() {
const {token, setToken} = useTokenValue()
const location = useLocation()
const previousLocation = location.state?.previousLocation
if (token) {
return(
<>
<Routes location={previousLocation || location}>
<Route path="/" element={<Home />} />
<Route path="/:id" element={<Board />} />
</Routes>
{previousLocation && (
<Routes>
<Route path="/:id/:id" element={<CardModal previousLocation={previousLocation} />} />
</Routes>
)}
</>
)
}
return (
// ...
)
}
`useNavigate` Hook is used to redirect to the Board page when the modal is closed.
import { useNavigate } from 'react-router-dom';
export function CardModal({previousLocation}) {
const navigate = useNavigate();
return (
<div
onClick={() => navigate(`${previousLocation.pathname}`)}
>
<div
onClick={e => e.stopPropagation()}
>
{/* Content */}
</div>
</div>
);
}
Using [body-scroll-lock](https://www.npmjs.com/package/body-scroll-lock) `disableBodyScroll` and `enableBodyScroll` functions when the modal component is mounted and unmounted, respectively.
import { useEffect, useRef } from "react";
import { useNavigate } from 'react-router-dom';
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
export function CardModal({previousLocation}) {
const modalRef = useRef();
const navigate = useNavigate();
useEffect(() => {
const observerRefValue = modalRef.current;
disableBodyScroll(observerRefValue);
return () => {
if (observerRefValue) {
enableBodyScroll(observerRefValue);
}
};
}, []);
return (
<div
ref={modalRef}
onClick={() => navigate(`${previousLocation.pathname}`)}
>
<div
onClick={e => e.stopPropagation()}
>
{/* Content */}
</div>
</div>
);
}
``
<h3 className="text-lg font-semibold">Using `useParams`</h3>
[`useParams`](focus://1,10,13) hook was used to get the `boardId` and `cardId` from the url params to fetch the data from the server.
```tsx CardModal.tsx
import { useParams } from "react-router-dom";
import { useQuery } from "@apollo/client";
import {
FindCardDocument,
FindCardQuery,
FindCardQueryVariables,
} from "@/generated/graphql";
export function CardModal({previousLocation}) {
const { id } = useParams();
const { data, loading, error } = useQuery<FindCardQuery, FindCardQueryVariables>(FindCardDocument, {
variables: {
findCardId: id as string
},
onCompleted: (data) => {
if (data.findCard?.description) {
setDescription(data.findCard?.description.replace(/<br \/>/g, '\n') as string);
}
}
});
return (
// ...
);
}
if card has a description it is set to the state using setDescription
replacing <br />
with \n
to display the description in a textarea.
Using [React Hook Form](https://react-hook-form.com/)
Was used to handle the form submission and display the form state.
import { useForm } from "react-hook-form";
export default function CardModal(){
const form = useForm()
return(
// ...
<>
<Button
variant="destructive"
className="m-4 flex flex-end gap-2 rounded-lg shadow-sm"
onClick={form.handleSubmit(handleDeleteCard)}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? <><Loader2 className="animate-spin" /> Deleting...</> : "Delete Card"}
</Button>
</>
)
}
Splitting up the schema types and the associated resolvers into separate files and importing them in the main schema file.
const { makeExecutableSchema } = require('@graphql-tools/schema')
const merge = require('lodash/merge')
const {
typeDefs: User,
resolvers: userResolvers
} = require('./schemas/user')
const {
typeDefs: Board,
resolvers: boardResolvers
} = require('./schemas/board')
const Query = `
type Query {
test: String
}
type Mutation {
_empty: String
}
`;
const resolvers = {
Query: {
test: () => 'Hello World'
},
Mutation: {}
};
const schema = makeExecutableSchema({
typeDefs: [Query, User, Board],
resolvers: merge(resolvers, userResolvers, boardResolvers),
logger: { log: e => console.log(e) }
});
module.exports = schema
const typeDefs = `
// ...
type User {
name: String!
username: String!
boards: [Board!]!
id: ID!
}
extend type Query {
me: User
}
extend type Mutation {
// ...
}
`
const resolvers = {
Query: {
// ...
},
Mutation: {
// ...
}
}
module.exports = {
typeDefs,
resolvers
}
const typeDefs = `
type Attachment {
id: ID!
url: String!
name: String!
uploaded_at: String!
open_graph_image: String
}
type Image {
id: ID!
url: String!
key: String!
name: String!
uploaded_at: String!
}
type Card {
title: String!
description: String
id: ID!
attachments: [Attachment!]
images: [Image!]
cover: String
}
type List {
title: String!
cards: [Card!]!
id: ID!
}
type Board {
title: String!
description: String
created_at: String
updated_at: String
saved: Boolean
saved_at: String
lists: [List!]!
bg: String!
uploaded_bgs: [Image!]
id: ID!
user: User!
listsOrder: [List!]!
}
extend type Query {
// ...
}
extend type Mutation {
// ...
}
`
const resolvers = {
Query: {
// ...
},
Mutation: {
// ...
}
}
module.exports = {
typeDefs,
resolvers
}
extending the Query
and Mutation
types in each schema file and merging them in the main schema file using merge
function from lodash
library.
logging in would return a token (jsonwebtoken
) which is used to authenticate the user by decoding the token and finding the user in the database.
login: async (root, args) => {
const user = await User.findOne({ username: args.username })
const passwordCorrect = user === null
? false
: await bcrypt.compare(args.password, user.passwordHash)
if (!(user && passwordCorrect)) {
throw new GraphQLError('invalid username or password', {
extensions: {
code: 'UNAUTHENTICATED'
}
})
}
const userForToken = {
username: user.username,
id: user._id
}
return { value: jwt.sign(userForToken, process.env.JWT_SECRET) }
}
const jwt = require('jsonwebtoken')
const User = require('../models/user')
require('dotenv').config()
const userContext = async ({ req, res }) => {
const auth = req ? req.headers.authorization : null
if(auth && auth.toLowerCase().startsWith('bearer ')) {
const decodedToken = jwt.verify(
auth.substring(7), process.env.JWT_SECRET
)
const currentUser = await User.findById(decodedToken.id)
return { currentUser }
}
}
module.exports = userContext
Using [cheerio](https://cheerio.js.org/) to get Open Graph Image
const { getOpenGraphImage } = require('../utils/opengraph')
const resolvers = {
Query: {
// ...
},
Mutation: {
// ...
addAttachment: async (root, args, { currentUser }) => {
// ...
const ogImage = await getOpenGraphImage(attachment.url)
if(ogImage) {
attachment.open_graph_image = ogImage
}
// ...
}
}
}
const axios = require('axios');
const cheerio = require('cheerio');
async function getOpenGraphImage(url) {
const { data } = await axios.get(url);
const $ = cheerio.load(data);
const ogImage = $('meta[property="og:image"]').attr('content');
return ogImage;
}
module.exports = {
getOpenGraphImage,
};
Using [uploadthing](https://docs.uploadthing.com/) to handle file uploads
Uploadthing simplifies uploading files to S3, while owning and having full control over the service.
requests are made to my /api/uploadthing
endpoint.
import cors from 'cors';
import "dotenv/config"
import express, { Request, Response } from 'express';
import { createUploadthingExpressHandler } from "uploadthing/express"
import { UTApi } from 'uploadthing/server';
import { uploadRouter } from './router'
const app = express();
const utapi = new UTApi()
app.use(cors());
app.get('/', (req, res) => res.send('Hello from uploadthing api'));
app.use(
'/api/uploadthing',
createUploadthingExpressHandler({
router: uploadRouter,
})
)
const deleteRouter = express.Router()
deleteRouter.post('/:id', async (req:Request, res:Response) => {
const key = req.params.id
if (!key) {
res.status(400).send('missing key')
return
}
try {
await utapi.deleteFiles(key)
res.status(200).send('deleted')
} catch (err) {
res.status(500).send(err)
}
})
app.use('/api/uploadthing/delete', deleteRouter)
app.listen(process.env.PORT || 3000, () => console.log(`Server listening on port ${process.env.PORT || 3000}!`) );
import { createUploadthing, type FileRouter } from "uploadthing/server";
const f = createUploadthing({
errorFormatter: (err) => {
console.log("Error uploading file", err.message);
console.log(" - Above error caused by:", err.cause);
return { message: err.message };
},
});
export const uploadRouter = {
image: f({
image: {
maxFileSize: "4MB",
maxFileCount: 1
}
}).onUploadComplete((data) => {
console.log("upload completed", data);
}),
} satisfies FileRouter;
export type OurFileRouter = typeof uploadRouter;