[인프런 워밍업 클럽 Full Stack 3기] 2주차 Dropbox
Part 1. Git Repository 생성 및 초기 설정 진행초기 코드 생성npx create-next-app@latest inflearn-supabase-dropbox-clone cd inflearn-supabase-dropbox-cloneTODO List 코드 복사config/*app/layout.tsx , app/middleware.tsx , app/global.csscomponents/material-tailwind-theme-provider.tsxutils/*package.jsontailwind.config.ts, tsconfig.json , .envPart 2. UI 작업app/page.tsximport UI from "./ui"; export const metadata = { title: "Minibox", description: "A minimalistic Dropbox clone", }; export default function Home() { return <UI />; }app/ui.tsx"use client"; import DropboxImageList from "components/dropbox-image-list"; import FileDragDropZone from "components/file-dragdropzone"; import Logo from "components/logo"; import SearchComponent from "components/search-component"; import Image from "next/image"; import { useState } from "react"; export default function UI() { const [searchInput, setSearchInput] = useState(""); return ( <main className="w-full p-2 flex flex-col gap-4"> {/* Logo */} <Logo /> {/* Search Component */} <SearchComponent searchInput={searchInput} setSearchInput={setSearchInput} /> {/* File Drag&Drop Zone */} <FileDragDropZone /> {/* Dropbox Image List */} <DropboxImageList /> </main> ); }components/dropbox-image-list.tsx"use client"; import DropboxImage from "./dropbox-image"; export default function DropboxImageList() { return ( <section className="grid md:grid-cols-3 lg:grid-cols-4 grid-cols-2"> <DropboxImage /> <DropboxImage /> <DropboxImage /> <DropboxImage /> </section> ); } components/dropbox-image.tsx"use client"; import { IconButton } from "@material-tailwind/react"; export default function DropboxImage() { return ( <div className="relative w-full flex flex-col gap-2 p-4 border border-gray-100 rounded-2xl shadow-md"> {/* Image */} <div> <img src="/images/cutedog.jpeg" className="w-full aspect-square rounded-2xl" /> </div> {/* FileName */} <div className="">cutedog.jpeg</div> <div className="absolute top-4 right-4"> <IconButton onClick={() => {}} color="red"> <i className="fas fa-trash" /> </IconButton> </div> </div> ); } components/file-dragdropzone.tsx"use client"; export default function FileDragDropZone() { return ( <section className="w-full py-20 border-4 border-dotted border-indigo-700 flex flex-col items-center justify-center"> <input type="file" className="" /> <p>파일을 여기에 끌어다 놓거나 클릭하여 업로드하세요.</p> </section> ); } components/logo.tsx"use client"; import Image from "next/image"; export default function Logo() { return ( <div className="flex items-center gap-1"> <Image src="/images/dropbox_icon.png" alt="Mini Dropbox Logo" width={50} height={30} className="!w-8 !h-auto" /> <span className="text-xl font-bold">Minibox</span> </div> ); } components/search-component.tsx "use client"; import { Input } from "@material-tailwind/react"; import { useState } from "react"; export default function SearchComponent({ searchInput, setSearchInput }) { return ( <Input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} label="Search Images" icon={<i className="fa-solid fa-magnifying-glass" />} /> ); } Part 3. Supabase Storage 설명 & 파일 업로드 구현Supabase Storage Bucket 생성 및 CRUD Policy 추가 액션 함수 구현actions/storageActions.tsx"use server"; import { createServerSupabaseClient } from "utils/supabase/server"; function handleError(error) { if (error) { console.error(error); throw error; } } export async function uploadFile(formData: FormData) { const supabase = await createServerSupabaseClient(); const file = formData.get("file") as File; const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .upload(file.name, file, { upsert: true }); handleError(error); return data; } export async function searchFiles(search: string = "") { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .list(null, { search, }); handleError(error); return data; } components/file-dragdropzone.tsx"use client"; import { Button } from "@material-tailwind/react"; import { useMutation } from "@tanstack/react-query"; import { uploadFile } from "actions/storageActions"; import { queryClient } from "config/ReactQueryClientProvider"; import { useRef } from "react"; export default function FileDragDropZone() { const fileRef = useRef(null); const uploadImageMutation = useMutation({ mutationFn: uploadFile, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["images"], }); }, }); return ( <form onSubmit={async (e) => { e.preventDefault(); const file = fileRef.current.files?.[0]; if (file) { const formData = new FormData(); formData.append("file", file); const result = await uploadImageMutation.mutate(formData); console.log(result); } }} className="w-full py-20 border-4 border-dotted border-indigo-700 flex flex-col items-center justify-center" > <input ref={fileRef} type="file" className="" /> <p>파일을 여기에 끌어다 놓거나 클릭하여 업로드하세요.</p> <Button loading={uploadImageMutation.isPending} type="submit"> 파일 업로드 </Button> </form> ); }Part 4. 필수 라이브러리 설정 - React Query, Supabase라이브러리 추가 설치npm i --save @supabase/ssr @tanstack/react-queryReact Query 설정config/ReactQueryClientProvider.tsx"use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; export const queryClient = new QueryClient({}); export default function ReactQueryClientProvider({ children, }: React.PropsWithChildren) { return ( <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ); }app/layout.tsximport ReactQueryClientProvider from "config/ReactQueryClientProvider"; export default function RootLayout({ children }) { return ( <ReactQueryClientProvider> ... </ReactQueryClientProvider> ) } Supabase 설정.envNEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_SUPABASE_SERVICE_ROLE= NEXT_SUPABASE_DB_PASSWORD= utils/supabase/client.ts"use client"; import { createBrowserClient } from "@supabase/ssr"; export const createBrowserSupabaseClient = () => createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! );utils/supabase/server.ts"use server"; import { createServerClient, type CookieOptions } from "@supabase/ssr"; import { cookies } from "next/headers"; import { Database } from "types_db"; export const createServerSupabaseClient = async ( cookieStore: ReturnType<typeof cookies> = cookies(), admin: boolean = false ) => { return createServerClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, admin ? process.env.NEXT_SUPABASE_SERVICE_ROLE! : process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { get(name: string) { return cookieStore.get(name)?.value; }, set(name: string, value: string, options: CookieOptions) { try { cookieStore.set({ name, value, ...options }); } catch (error) { // The `set` method was called from a Server Component. // This can be ignored if you have middleware refreshing // user sessions. } }, remove(name: string, options: CookieOptions) { try { cookieStore.set({ name, value: "", ...options }); } catch (error) { // The `delete` method was called from a Server Component. // This can be ignored if you have middleware refreshing // user sessions. } }, }, } ); }; export const createServerSupabaseAdminClient = async ( cookieStore: ReturnType<typeof cookies> = cookies() ) => { return createServerSupabaseClient(cookieStore, true); }; app/middleware.tsimport { createServerClient, type CookieOptions } from "@supabase/ssr"; import { type NextRequest, NextResponse } from "next/server"; export const applyMiddlewareSupabaseClient = async (request: NextRequest) => { // Create an unmodified response let response = NextResponse.next({ request: { headers: request.headers, }, }); const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { get(name: string) { return request.cookies.get(name)?.value; }, set(name: string, value: string, options: CookieOptions) { // If the cookie is updated, update the cookies for the request and response request.cookies.set({ name, value, ...options, }); response = NextResponse.next({ request: { headers: request.headers, }, }); response.cookies.set({ name, value, ...options, }); }, remove(name: string, options: CookieOptions) { // If the cookie is removed, update the cookies for the request and response request.cookies.set({ name, value: "", ...options, }); response = NextResponse.next({ request: { headers: request.headers, }, }); response.cookies.set({ name, value: "", ...options, }); }, }, } ); // refreshing the auth token await supabase.auth.getUser(); return response; }; export async function middleware(request) { return await applyMiddlewareSupabaseClient(request); } export const config = { matcher: [ /* * Match all request paths except for the ones starting with: * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) * Feel free to modify this pattern to include more paths. */ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], }; Part 5. 할일 CRUD 기능 구현 (feat. Server Action)"use server"; import { Database } from "types_db"; import { createServerSupabaseClient } from "utils/supabase/server"; export type TodoRow = Database["public"]["Tables"]["todo"]["Row"]; export type TodoRowInsert = Database["public"]["Tables"]["todo"]["Insert"]; export type TodoRowUpdate = Database["public"]["Tables"]["todo"]["Update"]; function handleError(error) { console.error(error); throw new Error(error.message); } export async function getTodos({ searchInput = "" }): Promise<TodoRow[]> { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("todo") .select("*") .like("title", `%${searchInput}%`) .order("created_at", { ascending: true }); if (error) { handleError(error); } return data; } export async function createTodo(todo: TodoRowInsert) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.from("todo").insert({ ...todo, created_at: new Date().toISOString(), }); if (error) { handleError(error); } return data; } export async function updateTodo(todo: TodoRowUpdate) { const supabase = await createServerSupabaseClient(); console.log(todo); const { data, error } = await supabase .from("todo") .update({ ...todo, updated_at: new Date().toISOString(), }) .eq("id", todo.id); if (error) { handleError(error); } return data; } export async function deleteTodo(id: number) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.from("todo").delete().eq("id", id); if (error) { handleError(error); } return data; }