├── .next ├── types │ └── package.json └── server │ ├── pages-manifest.json │ ├── server-reference-manifest.json │ ├── server-reference-manifest.js │ ├── app-paths-manifest.json │ └── middleware-manifest.json ├── .eslintrc.json ├── app ├── spotify.ico ├── globals.css ├── providers │ ├── ToastProvider.tsx │ ├── UserProvider.tsx │ ├── SupabaseProvider.tsx │ └── ModalProvider.tsx ├── account │ ├── error.tsx │ ├── loading.tsx │ ├── page.tsx │ └── components │ │ └── AccountContent.tsx ├── libs │ └── supabaseAdmin.ts ├── liked │ ├── loading.tsx │ ├── page.tsx │ └── components │ │ └── LikedContent.tsx ├── search │ ├── loading.tsx │ ├── page.tsx │ └── components │ │ └── SearchContent.tsx ├── utils │ └── getURL.ts ├── (site) │ ├── components │ │ └── PageContent.tsx │ └── page.tsx ├── api │ ├── create-portal-link │ │ └── route.ts │ ├── create-checkout-session │ │ └── route.ts │ └── webhooks │ │ └── route.ts ├── layout.tsx └── interfaces │ └── types_db.ts ├── public ├── images │ └── liked.png ├── vercel.svg └── next.svg ├── postcss.config.js ├── providers ├── ToasterProvider.tsx ├── UserProvider.tsx ├── ModalProvider.tsx └── SupabaseProvider.tsx ├── libs ├── stripe.ts ├── stripeClient.ts ├── helpers.ts └── supabaseAdmin.ts ├── next.config.js ├── tailwind.config.ts ├── .env.example ├── middleware.ts ├── components ├── Box.tsx ├── PlayButton.tsx ├── SidebarItem.tsx ├── Player.tsx ├── Button.tsx ├── SearchInput.tsx ├── Input.tsx ├── ListItem.tsx ├── Slider.tsx ├── MediaItem.tsx ├── AuthModal.tsx ├── Sidebar.tsx ├── Library.tsx ├── SongItem.tsx ├── LikeButton.tsx ├── Modal.tsx ├── SubscribeModal.tsx ├── Header.tsx ├── UploadModal.tsx └── PlayerContent.tsx ├── hooks ├── useAuthModal.ts ├── useUploadModal.ts ├── useSubscribeModal.ts ├── useLoadImage.ts ├── useLoadSongUrl.ts ├── useDebounce.ts ├── usePlayer.ts ├── useOnPlay.ts ├── useGetSongById.ts └── useUser.tsx ├── env.d.ts ├── .gitignore ├── actions ├── getSongs.ts ├── getLikedSongs.ts ├── getActiveProductsWithPrices.ts ├── getSongsByTitle.ts ├── getSongsById.ts └── getSongsByUserId.ts ├── tsconfig.json ├── .prettierrc.yaml ├── package.json ├── types.ts ├── README.md └── types_db.ts /.next/types/package.json: -------------------------------------------------------------------------------- 1 | {"type": "module"} -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/spotify.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicitaacom/19_spotify-clone/HEAD/app/spotify.ico -------------------------------------------------------------------------------- /public/images/liked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicitaacom/19_spotify-clone/HEAD/public/images/liked.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.next/server/pages-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/_app": "pages/_app.js", 3 | "/_error": "pages/_error.js", 4 | "/_document": "pages/_document.js" 5 | } -------------------------------------------------------------------------------- /.next/server/server-reference-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "node": {}, 3 | "edge": {}, 4 | "encryptionKey": "MQA2kHrEh5NtXPXjl4/nFkTLJ9MfEH5+drFCTkYLQso=" 5 | } -------------------------------------------------------------------------------- /.next/server/server-reference-manifest.js: -------------------------------------------------------------------------------- 1 | self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"MQA2kHrEh5NtXPXjl4/nFkTLJ9MfEH5+drFCTkYLQso=\"\n}" -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | height: 100%; 9 | background-color: black; 10 | color-scheme: dark; 11 | } 12 | -------------------------------------------------------------------------------- /.next/server/app-paths-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/(site)/page": "app/(site)/page.js", 3 | "/account/page": "app/account/page.js", 4 | "/api/create-checkout-session/route": "app/api/create-checkout-session/route.js" 5 | } -------------------------------------------------------------------------------- /app/providers/ToastProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { Toaster } from "react-hot-toast" 3 | 4 | export default function ToasterProvider() { 5 | return 6 | } 7 | -------------------------------------------------------------------------------- /providers/ToasterProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Toaster } from "react-hot-toast" 4 | 5 | export default function ToasterProvider() { 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /libs/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe" 2 | 3 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "", { 4 | apiVersion: "2022-11-15", 5 | appInfo: { 6 | name: "Spotify Clone", 7 | version: "0.1.0", 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /app/account/error.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import Box from "@/components/Box" 3 | 4 | export default function Error() { 5 | return ( 6 | 7 |
Something went wrong
8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: "https", 7 | hostname: "eqhntdpwtnhqzcnpxegh.supabase.co", 8 | port: "", 9 | }, 10 | ], 11 | }, 12 | } 13 | 14 | module.exports = nextConfig 15 | -------------------------------------------------------------------------------- /app/libs/supabaseAdmin.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "@/app/interfaces/types_db" 2 | import { createClient } from "@supabase/supabase-js" 3 | 4 | export const supabaseAdmin = createClient( 5 | process.env.NEXT_PUBLIC_SUPABASE_URL, 6 | process.env.SUPABASE_SERVICE_ROLE_KEY, 7 | ) 8 | 9 | export default supabaseAdmin 10 | -------------------------------------------------------------------------------- /libs/stripeClient.ts: -------------------------------------------------------------------------------- 1 | import { loadStripe, Stripe } from "@stripe/stripe-js" 2 | 3 | let stripePromise: Promise 4 | 5 | export const getStripe = () => { 6 | if (!stripePromise) { 7 | stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "") 8 | } 9 | 10 | return stripePromise 11 | } 12 | -------------------------------------------------------------------------------- /app/liked/loading.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { BounceLoader } from "react-spinners" 3 | 4 | import Box from "@/components/Box" 5 | 6 | export default function Loading() { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /app/account/loading.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { BounceLoader } from "react-spinners" 3 | 4 | import Box from "@/components/Box" 5 | 6 | export default function Loading() { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /app/search/loading.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { BounceLoader } from "react-spinners" 3 | 4 | import Box from "@/components/Box" 5 | 6 | export default function Loading() { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /providers/UserProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { MyUserContextProvider } from "@/hooks/useUser" 4 | 5 | interface UserProviderProps { 6 | children: React.ReactNode 7 | } 8 | 9 | export default function UserProvider({ children }: UserProviderProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config: Config = { 4 | content: [ 5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: {}, 11 | }, 12 | plugins: [], 13 | } 14 | export default config 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SUPABASE_URL='' 2 | NEXT_PUBLIC_SUPABASE_ANON_KEY='' 3 | SUPABASE_SERVICE_ROLE_KEY='' 4 | 5 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY='' 6 | STRIPE_SECRET_KEY='' 7 | STRIPE_WEBHOOK_SECRET='' 8 | 9 | # supabase account 10 | # 11 | # 12 | # Supabase password: 13 | # Supabase access token: 14 | 15 | # password for stripe CLI 16 | # stripe web-hook key -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { createMiddlewareClient } from "@supabase/auth-helpers-nextjs" 2 | import { NextRequest, NextResponse } from "next/server" 3 | 4 | export async function middleware(req: NextRequest) { 5 | const res = NextResponse.next() 6 | const supabase = createMiddlewareClient({ req, res }) 7 | await supabase.auth.getSession() 8 | return res 9 | } 10 | -------------------------------------------------------------------------------- /components/Box.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import { twMerge } from "tailwind-merge" 4 | 5 | interface BoxProps { 6 | children: React.ReactNode 7 | className?: string 8 | } 9 | 10 | export default function Box({ children, className }: BoxProps) { 11 | return
{children}
12 | } 13 | -------------------------------------------------------------------------------- /app/providers/UserProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { MyUserContextProvider } from "@/hooks/useUser" 4 | 5 | interface UserProviderProps { 6 | children: React.ReactNode 7 | } 8 | 9 | const UserProvider: React.FC = ({ children }) => { 10 | return {children} 11 | } 12 | 13 | export default UserProvider 14 | -------------------------------------------------------------------------------- /hooks/useAuthModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand" 2 | 3 | interface AuthModalStore { 4 | isOpen: boolean 5 | onOpen: () => void 6 | onClose: () => void 7 | } 8 | 9 | const useAuthModal = create(set => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })) 14 | 15 | export default useAuthModal 16 | -------------------------------------------------------------------------------- /hooks/useUploadModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand" 2 | 3 | interface UploadModalStore { 4 | isOpen: boolean 5 | onOpen: () => void 6 | onClose: () => void 7 | } 8 | 9 | const useUploadModal = create(set => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })) 14 | 15 | export default useUploadModal 16 | -------------------------------------------------------------------------------- /hooks/useSubscribeModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand" 2 | 3 | interface SubscribeModalStore { 4 | isOpen: boolean 5 | onOpen: () => void 6 | onClose: () => void 7 | } 8 | 9 | const useSubscribeModal = create(set => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })) 14 | 15 | export default useSubscribeModal 16 | -------------------------------------------------------------------------------- /hooks/useLoadImage.ts: -------------------------------------------------------------------------------- 1 | import { useSupabaseClient } from "@supabase/auth-helpers-react" 2 | 3 | import { Song } from "@/types" 4 | 5 | const useLoadImage = (song: Song) => { 6 | const supabaseClient = useSupabaseClient() 7 | 8 | if (!song) { 9 | return null 10 | } 11 | 12 | const { data: imageData } = supabaseClient.storage.from("images").getPublicUrl(song.image_path) 13 | 14 | return imageData.publicUrl 15 | } 16 | 17 | export default useLoadImage 18 | -------------------------------------------------------------------------------- /hooks/useLoadSongUrl.ts: -------------------------------------------------------------------------------- 1 | import { useSupabaseClient } from "@supabase/auth-helpers-react" 2 | 3 | import { Song } from "@/types" 4 | 5 | const useLoadSongUrl = (song: Song) => { 6 | const supabaseClient = useSupabaseClient() 7 | 8 | if (!song) { 9 | return "" 10 | } 11 | 12 | const { data: songData } = supabaseClient.storage.from("songs").getPublicUrl(song.song_path) 13 | 14 | return songData.publicUrl 15 | } 16 | 17 | export default useLoadSongUrl 18 | -------------------------------------------------------------------------------- /hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | function useDebounce(value: T, delay?: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value) 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => setDebouncedValue(value), delay || 500) 8 | 9 | return () => { 10 | clearTimeout(timer) 11 | } 12 | }, [value, delay]) 13 | 14 | return debouncedValue 15 | } 16 | 17 | export default useDebounce 18 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | NEXT_PRODUCTION_URL: string 5 | 6 | NEXT_PUBLIC_SUPABASE_URL: string 7 | NEXT_PUBLIC_SUPABASE_ANON_KEY: string 8 | SUPABASE_SERVICE_ROLE_KEY: string 9 | 10 | GITHUB_CLIENT_ID: string 11 | GITHUB_CLIENT_SECRET: string 12 | 13 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: string 14 | STRIPE_SECRET_KEY: string 15 | STRIPE_WEBHOOK_SECRET: string 16 | } 17 | } 18 | } 19 | 20 | export {} 21 | -------------------------------------------------------------------------------- /hooks/usePlayer.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand" 2 | 3 | interface PlayerStore { 4 | ids: string[] 5 | activeId?: string 6 | setId: (id: string) => void 7 | setIds: (ids: string[]) => void 8 | reset: () => void 9 | } 10 | 11 | const usePlayer = create(set => ({ 12 | ids: [], 13 | activeId: undefined, 14 | setId: (id: string) => set({ activeId: id }), 15 | setIds: (ids: string[]) => set({ ids }), 16 | reset: () => set({ ids: [], activeId: undefined }), 17 | })) 18 | 19 | export default usePlayer 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /app/utils/getURL.ts: -------------------------------------------------------------------------------- 1 | //This function may be user on client side and server side 2 | export const getURL = () => { 3 | // if you change port - change it here as well 4 | let url = 5 | process.env.NODE_ENV === "development" 6 | ? "http://localhost:3000" 7 | : process.env.NEXT_PRODUCTION_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? process.env.NEXT_PUBLIC_VERCEL_URL 8 | 9 | url = url?.includes("http") ? url : `https://${url}` 10 | url = url.charAt(url.length - 1) === "/" ? url : `${url}/` 11 | 12 | return url 13 | } 14 | -------------------------------------------------------------------------------- /providers/ModalProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import AuthModal from "@/components/AuthModal" 4 | import UploadModal from "@/components/UploadModal" 5 | import { useEffect, useState } from "react" 6 | 7 | export default function ModalProvider() { 8 | const [isMounted, setIsMounted] = useState(false) 9 | 10 | useEffect(() => { 11 | setIsMounted(true) 12 | }, []) 13 | 14 | if (!isMounted) { 15 | return null 16 | } 17 | 18 | return ( 19 | <> 20 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /.next/server/middleware-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "sortedMiddleware": [ 3 | "/" 4 | ], 5 | "middleware": { 6 | "/": { 7 | "files": [ 8 | "server/edge-runtime-webpack.js", 9 | "server/middleware.js" 10 | ], 11 | "name": "middleware", 12 | "page": "/", 13 | "matchers": [ 14 | { 15 | "regexp": "^/.*$", 16 | "originalSource": "/:path*" 17 | } 18 | ], 19 | "wasm": [], 20 | "assets": [] 21 | } 22 | }, 23 | "functions": {}, 24 | "version": 2 25 | } -------------------------------------------------------------------------------- /app/account/page.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@/components/Header" 2 | import AccountContent from "./components/AccountContent" 3 | 4 | export default function Page() { 5 | return ( 6 |
7 |
8 |
9 |

Account Settings

10 |
11 |
12 | 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /actions/getSongs.ts: -------------------------------------------------------------------------------- 1 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs" 2 | import { cookies } from "next/headers" 3 | 4 | import { Song } from "@/types" 5 | 6 | const getSongs = async (): Promise => { 7 | const supabase = createServerComponentClient({ 8 | cookies: cookies, 9 | }) 10 | 11 | const { data, error } = await supabase.from("songs").select("*").order("created_at", { ascending: false }) 12 | 13 | if (error) { 14 | console.log(17, "getSongs error - ", error.message) 15 | } 16 | 17 | return (data as any) || [] 18 | } 19 | 20 | export default getSongs 21 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/PlayButton.tsx: -------------------------------------------------------------------------------- 1 | import { FaPlay } from "react-icons/fa" 2 | 3 | const PlayButton = () => { 4 | return ( 5 | 24 | ) 25 | } 26 | 27 | export default PlayButton 28 | -------------------------------------------------------------------------------- /providers/SupabaseProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | 5 | import { Database } from "@/types_db" 6 | import { createClientComponentClient } from "@supabase/auth-helpers-nextjs" 7 | import { SessionContextProvider } from "@supabase/auth-helpers-react" 8 | 9 | interface SupabaseProviderProps { 10 | children: React.ReactNode 11 | } 12 | 13 | const SupabaseProvider: React.FC = ({ children }) => { 14 | const [supabaseClient] = useState(() => createClientComponentClient()) 15 | 16 | return {children} 17 | } 18 | 19 | export default SupabaseProvider 20 | -------------------------------------------------------------------------------- /app/providers/SupabaseProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { createClientComponentClient } from "@supabase/auth-helpers-nextjs" 5 | import { SessionContextProvider } from "@supabase/auth-helpers-react" 6 | 7 | import { Database } from "@/types_db" 8 | 9 | interface SupabaseProviderProps { 10 | children: React.ReactNode 11 | } 12 | 13 | const SupabaseProvider: React.FC = ({ children }) => { 14 | const [supabaseClient] = useState(() => createClientComponentClient()) 15 | 16 | return {children} 17 | } 18 | 19 | export default SupabaseProvider 20 | -------------------------------------------------------------------------------- /libs/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Price } from "@/types" 2 | 3 | export const postData = async ({ url, data }: { url: string; data?: { price: Price } }) => { 4 | console.log("POST REQUEST", url, data) 5 | 6 | const res: Response = await fetch(url, { 7 | method: "POST", 8 | headers: new Headers({ "Content-Type": "application/json" }), 9 | credentials: "same-origin", 10 | body: JSON.stringify(data), 11 | }) 12 | 13 | if (!res.ok) { 14 | console.log("Error in POST", { url, data, res }) 15 | 16 | throw new Error(res.statusText) 17 | } 18 | 19 | return res.json() 20 | } 21 | 22 | export const toDateTime = (secs: number) => { 23 | var t = new Date("1970-01-01T00:30:00Z") 24 | t.setSeconds(secs) 25 | return t 26 | } 27 | -------------------------------------------------------------------------------- /actions/getLikedSongs.ts: -------------------------------------------------------------------------------- 1 | import { Song } from "@/types" 2 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs" 3 | import { cookies } from "next/headers" 4 | 5 | const getLikedSongs = async (): Promise => { 6 | const supabase = createServerComponentClient({ 7 | cookies: cookies, 8 | }) 9 | 10 | const { 11 | data: { session }, 12 | } = await supabase.auth.getSession() 13 | 14 | const { data } = await supabase 15 | .from("liked_songs") 16 | .select("*, songs(*)") 17 | .eq("user_id", session?.user?.id) 18 | .order("created_at", { ascending: false }) 19 | 20 | if (!data) return [] 21 | 22 | return data.map(item => ({ 23 | ...item.songs, 24 | })) 25 | } 26 | 27 | export default getLikedSongs 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | }, 24 | "forceConsistentCasingInFileNames": true 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | # { 2 | # "trailingComma": "none", 3 | # "printWidth": 120, 4 | # "semi": false, 5 | # "tabWidth": 2, 6 | # "singleQuote": false, 7 | # "bracketSameLine": true, 8 | # "bracketSpacing": false, 9 | # "singleAttributePerLine": false, 10 | # "arrowParens": "avoid", 11 | # "endOfLine": "crlf" 12 | # } 13 | 14 | printWidth: 120 #https://i.imgur.com/4R3sG7H.png 15 | semi: false #https://i.imgur.com/cIQYB4m.png 16 | tabWidth: 2 #https://i.imgur.com/JNZQMad.png 17 | singleQuote: false #https://i.imgur.com/KCRIRIQ.png 18 | bracketSameLine: true #https://i.imgur.com/xiNXxVQ.png 19 | bracketSpacing: true #https://i.imgur.com/KQDFSvO.png 20 | singleAttributePerLine: false #https://i.imgur.com/mWlisKK.png 21 | arrowParens: "avoid" #https://i.imgur.com/uqJZTWo.png -------------------------------------------------------------------------------- /components/SidebarItem.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { IconType } from "react-icons" 3 | import { twMerge } from "tailwind-merge" 4 | 5 | interface SidebarItemProps { 6 | icon: IconType 7 | label: string 8 | active?: boolean 9 | href: string 10 | } 11 | 12 | const SidebarItem: React.FC = ({ icon: Icon, label, active, href }) => { 13 | return ( 14 | 21 | 22 |

{label}

23 | 24 | ) 25 | } 26 | 27 | export default SidebarItem 28 | -------------------------------------------------------------------------------- /hooks/useOnPlay.ts: -------------------------------------------------------------------------------- 1 | import { Song } from "@/types" 2 | 3 | import usePlayer from "./usePlayer" 4 | import useAuthModal from "./useAuthModal" 5 | import { useUser } from "./useUser" 6 | import useSubscribeModal from "./useSubscribeModal" 7 | 8 | const useOnPlay = (songs: Song[]) => { 9 | const subscribeModal = useSubscribeModal() 10 | const player = usePlayer() 11 | const authModal = useAuthModal() 12 | const { subscription, user } = useUser() 13 | 14 | const onPlay = (id: string) => { 15 | if (!user) { 16 | return authModal.onOpen() 17 | } 18 | 19 | if (!subscription) { 20 | return subscribeModal.onOpen() 21 | } 22 | 23 | player.setId(id) 24 | player.setIds(songs.map(song => song.id)) 25 | } 26 | 27 | return onPlay 28 | } 29 | 30 | export default useOnPlay 31 | -------------------------------------------------------------------------------- /app/providers/ModalProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { useEffect, useState } from "react" 3 | 4 | import AuthModal from "../../components/AuthModal" 5 | import UploadModal from "../../components/UploadModal" 6 | import SubscribeModal from "@/components/SubscribeModal" 7 | import { ProductWithPrice } from "@/types" 8 | 9 | interface ModalProviderProps { 10 | products: ProductWithPrice[] 11 | } 12 | 13 | export default function ModalProvider({ products }: ModalProviderProps) { 14 | const [isMounted, setIsMounted] = useState(false) 15 | 16 | useEffect(() => { 17 | setIsMounted(true) 18 | }, []) 19 | 20 | if (!isMounted) { 21 | return null 22 | } 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /actions/getActiveProductsWithPrices.ts: -------------------------------------------------------------------------------- 1 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs" 2 | import { cookies } from "next/headers" 3 | 4 | import { ProductWithPrice } from "@/types" 5 | 6 | const getActiveProductsWithPrices = async (): Promise => { 7 | const supabase = createServerComponentClient({ 8 | cookies: cookies, 9 | }) 10 | 11 | const { data, error } = await supabase 12 | .from("products") 13 | .select("*, prices(*)") 14 | .eq("active", true) 15 | .eq("prices.active", true) 16 | .order("metadata->index") 17 | .order("unit_amount", { foreignTable: "prices" }) 18 | 19 | if (error) { 20 | console.log(20, "getActiveProductsWithPrices - ", error.message) 21 | } 22 | 23 | return (data as any) || [] 24 | } 25 | 26 | export default getActiveProductsWithPrices 27 | -------------------------------------------------------------------------------- /components/Player.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import usePlayer from "@/hooks/usePlayer" 4 | import useLoadSongUrl from "@/hooks/useLoadSongUrl" 5 | import useGetSongById from "@/hooks/useGetSongById" 6 | 7 | import PlayerContent from "./PlayerContent" 8 | 9 | const Player = () => { 10 | const player = usePlayer() 11 | const { song } = useGetSongById(player.activeId) 12 | 13 | const songUrl = useLoadSongUrl(song!) 14 | 15 | if (!song || !songUrl || !player.activeId) { 16 | return null 17 | } 18 | 19 | return ( 20 |
30 | 31 |
32 | ) 33 | } 34 | 35 | export default Player 36 | -------------------------------------------------------------------------------- /actions/getSongsByTitle.ts: -------------------------------------------------------------------------------- 1 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs" 2 | import { cookies, headers } from "next/headers" 3 | 4 | import { Song } from "@/types" 5 | 6 | import getSongs from "./getSongs" 7 | 8 | const getSongsByTitle = async (title: string): Promise => { 9 | const supabase = createServerComponentClient({ 10 | cookies: cookies, 11 | }) 12 | 13 | if (!title) { 14 | const allSongs = await getSongs() 15 | return allSongs 16 | } 17 | 18 | const { data, error } = await supabase 19 | .from("songs") 20 | .select("*") 21 | .ilike("title", `%${title}%`) 22 | .order("created_at", { ascending: false }) 23 | 24 | if (error) { 25 | console.log(25, "error - ", error.message) 26 | } 27 | 28 | return (data as any) || [] 29 | } 30 | 31 | export default getSongsByTitle 32 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | interface ButtonProps extends React.ButtonHTMLAttributes {} 5 | 6 | const Button = forwardRef( 7 | ({ className, children, disabled, type = "button", ...props }, ref) => { 8 | return ( 9 | 22 | ) 23 | }, 24 | ) 25 | 26 | Button.displayName = "Button" 27 | 28 | export default Button 29 | -------------------------------------------------------------------------------- /components/SearchInput.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import qs from "query-string" 4 | import { useEffect, useState } from "react" 5 | import { useRouter } from "next/navigation" 6 | 7 | import useDebounce from "@/hooks/useDebounce" 8 | 9 | import Input from "./Input" 10 | 11 | const SearchInput = () => { 12 | const router = useRouter() 13 | const [value, setValue] = useState("") 14 | const debouncedValue = useDebounce(value, 500) 15 | 16 | useEffect(() => { 17 | const query = { 18 | title: debouncedValue, 19 | } 20 | 21 | const url = qs.stringifyUrl({ 22 | url: "/search", 23 | query, 24 | }) 25 | 26 | router.push(url) 27 | }, [debouncedValue, router]) 28 | 29 | return setValue(e.target.value)} /> 30 | } 31 | 32 | export default SearchInput 33 | -------------------------------------------------------------------------------- /app/search/page.tsx: -------------------------------------------------------------------------------- 1 | import getSongsByTitle from "@/actions/getSongsByTitle" 2 | import Header from "@/components/Header" 3 | import SearchInput from "@/components/SearchInput" 4 | import SearchContent from "./components/SearchContent" 5 | 6 | interface SearchProps { 7 | searchParams: { 8 | title: string 9 | } 10 | } 11 | 12 | export default async function Search({ searchParams }: SearchProps) { 13 | const songs = await getSongsByTitle(searchParams.title) 14 | 15 | return ( 16 |
17 |
18 |
19 |

Search

20 | 21 |
22 |
23 | 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /actions/getSongsById.ts: -------------------------------------------------------------------------------- 1 | import { Song } from "@/types" 2 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs" 3 | import { cookies } from "next/headers" 4 | 5 | const getSongsById = async (): Promise => { 6 | const supabase = createServerComponentClient({ 7 | cookies: cookies, 8 | }) 9 | const { data: sessionData, error: sessionError } = await supabase.auth.getSession() 10 | 11 | if (sessionError) { 12 | console.log(12, "session error in getSongsById - ", sessionError.message) 13 | return [] 14 | } 15 | 16 | const { data, error } = await supabase 17 | .from("songs") 18 | .select("*") 19 | .eq("user_id", sessionData.session?.user.id) 20 | .order("created_at", { ascending: false }) 21 | 22 | if (error) { 23 | console.log(23, "getSongsById error - ", error.message) 24 | } 25 | 26 | return (data as any) || [] 27 | } 28 | 29 | export default getSongsById 30 | -------------------------------------------------------------------------------- /app/(site)/components/PageContent.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Song } from "@/types" 4 | import useOnPlay from "@/hooks/useOnPlay" 5 | import SongItem from "@/components/SongItem" 6 | 7 | interface PageContentProps { 8 | songs: Song[] 9 | } 10 | 11 | const PageContent: React.FC = ({ songs }) => { 12 | const onPlay = useOnPlay(songs) 13 | 14 | if (songs.length === 0) { 15 | return
No songs available.
16 | } 17 | 18 | return ( 19 |
31 | {songs.map(item => ( 32 | onPlay(id)} key={item.id} data={item} /> 33 | ))} 34 |
35 | ) 36 | } 37 | 38 | export default PageContent 39 | -------------------------------------------------------------------------------- /actions/getSongsByUserId.ts: -------------------------------------------------------------------------------- 1 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs" 2 | import { cookies } from "next/headers" 3 | 4 | import { Song } from "@/types" 5 | 6 | const getSongsByUserId = async (): Promise => { 7 | const supabase = createServerComponentClient({ 8 | cookies: cookies, 9 | }) 10 | 11 | const { data: sessionData, error: sessionError } = await supabase.auth.getSession() 12 | 13 | if (sessionError) { 14 | console.log(14, "session error in getSongsByUserId - ", sessionError.message) 15 | return [] 16 | } 17 | if (!sessionData.session) { 18 | // it means user not authenticated 19 | return [] 20 | } 21 | 22 | const { data, error } = await supabase 23 | .from("songs") 24 | .select("*") 25 | .eq("user_id", sessionData.session?.user.id) 26 | .order("created_at", { ascending: false }) 27 | 28 | if (error) { 29 | console.log(25, "select songs eq user_id error in getSongsByUserId - ", error.message) 30 | } 31 | 32 | return (data as any) || [] 33 | } 34 | 35 | export default getSongsByUserId 36 | -------------------------------------------------------------------------------- /components/Input.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export interface InputProps extends React.InputHTMLAttributes {} 5 | 6 | const Input = forwardRef(({ className, type, disabled, ...props }, ref) => { 7 | return ( 8 | 37 | ) 38 | }) 39 | 40 | Input.displayName = "Input" 41 | 42 | export default Input 43 | -------------------------------------------------------------------------------- /app/(site)/page.tsx: -------------------------------------------------------------------------------- 1 | import getSongs from "@/actions/getSongs" 2 | import Header from "../../components/Header" 3 | import ListItem from "../../components/ListItem" 4 | import PageContent from "./components/PageContent" 5 | 6 | export const revalidate = 0 7 | 8 | export default async function Home() { 9 | const songs = await getSongs() 10 | 11 | return ( 12 |
13 |
14 |
15 |

Welcome back

16 |
17 |
18 | 19 |
20 |
21 |
22 |
23 |

Newest Songs

24 |
25 | 26 |
27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /hooks/useGetSongById.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react" 2 | import { toast } from "react-hot-toast" 3 | import { useSessionContext } from "@supabase/auth-helpers-react" 4 | 5 | import { Song } from "@/types" 6 | 7 | const useSongById = (id?: string) => { 8 | const [isLoading, setIsLoading] = useState(false) 9 | const [song, setSong] = useState(undefined) 10 | const { supabaseClient } = useSessionContext() 11 | 12 | useEffect(() => { 13 | if (!id) { 14 | return 15 | } 16 | 17 | setIsLoading(true) 18 | 19 | const fetchSong = async () => { 20 | const { data, error } = await supabaseClient.from("songs").select("*").eq("id", id).single() 21 | 22 | if (error) { 23 | setIsLoading(false) 24 | return toast.error(error.message) 25 | } 26 | 27 | setSong(data as Song) 28 | setIsLoading(false) 29 | } 30 | 31 | fetchSong() 32 | }, [id, supabaseClient]) 33 | 34 | return useMemo( 35 | () => ({ 36 | isLoading, 37 | song, 38 | }), 39 | [isLoading, song], 40 | ) 41 | } 42 | 43 | export default useSongById 44 | -------------------------------------------------------------------------------- /app/api/create-portal-link/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs" 2 | import { cookies } from "next/headers" 3 | import { NextResponse } from "next/server" 4 | 5 | import { stripe } from "@/libs/stripe" 6 | import { getURL } from "@/app/utils/getURL" 7 | import { createOrRetrieveCustomer } from "@/libs/supabaseAdmin" 8 | 9 | export async function POST() { 10 | try { 11 | const supabase = createRouteHandlerClient({ cookies }) 12 | 13 | const { 14 | data: { user }, 15 | } = await supabase.auth.getUser() 16 | 17 | if (!user) throw new Error("Could not get user") 18 | 19 | const customer = await createOrRetrieveCustomer({ 20 | uuid: user.id || "", 21 | email: user.email || "", 22 | }) 23 | 24 | if (!customer) throw new Error("Could not get customer") 25 | 26 | const { url } = await stripe.billingPortal.sessions.create({ 27 | customer, 28 | return_url: `${getURL()}/account`, 29 | }) 30 | 31 | return NextResponse.json({ url }) 32 | } catch (error: any) { 33 | console.log(error) 34 | return new NextResponse("Internal error", { status: 500 }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/liked/page.tsx: -------------------------------------------------------------------------------- 1 | import getLikedSongs from "@/actions/getLikedSongs" 2 | import Header from "@/components/Header" 3 | import Image from "next/image" 4 | import LikedContent from "./components/LikedContent" 5 | 6 | export const revalidate = 0 7 | 8 | export default async function Liked() { 9 | const songs = await getLikedSongs() 10 | 11 | return ( 12 |
13 |
14 |
15 |
16 |
17 | Playlist 18 |
19 |
20 |

Playlist

21 |

Liked songs

22 |
23 |
24 |
25 |
26 | 27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /app/search/components/SearchContent.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Song } from "@/types" 4 | import MediaItem from "@/components/MediaItem" 5 | import LikeButton from "@/components/LikeButton" 6 | import useOnPlay from "@/hooks/useOnPlay" 7 | 8 | interface SearchContentProps { 9 | songs: Song[] 10 | } 11 | 12 | const SearchContent: React.FC = ({ songs }) => { 13 | const onPlay = useOnPlay(songs) 14 | 15 | if (songs.length === 0) { 16 | return ( 17 |
26 | No songs found. 27 |
28 | ) 29 | } 30 | 31 | return ( 32 |
33 | {songs.map((song: Song) => ( 34 |
35 |
36 | onPlay(id)} data={song} /> 37 |
38 | 39 |
40 | ))} 41 |
42 | ) 43 | } 44 | 45 | export default SearchContent 46 | -------------------------------------------------------------------------------- /components/ListItem.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Image from "next/image" 4 | import { useRouter } from "next/navigation" 5 | 6 | import { FaPlay } from "react-icons/fa" 7 | 8 | interface ListItemProps { 9 | image: string 10 | name: string 11 | href: string 12 | } 13 | 14 | const ListItem: React.FC = ({ image, name, href }) => { 15 | const router = useRouter() 16 | 17 | const onClick = () => { 18 | //add auth before push 19 | router.push(href) 20 | } 21 | 22 | return ( 23 | 37 | ) 38 | } 39 | 40 | export default ListItem 41 | -------------------------------------------------------------------------------- /components/Slider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as RadixSlider from "@radix-ui/react-slider" 4 | 5 | interface SlideProps { 6 | value?: number 7 | onChange?: (value: number) => void 8 | } 9 | 10 | const Slider: React.FC = ({ value = 1, onChange }) => { 11 | const handleChange = (newValue: number[]) => { 12 | onChange?.(newValue[0]) 13 | } 14 | 15 | return ( 16 | 32 | 40 | 48 | 49 | 50 | ) 51 | } 52 | 53 | export default Slider 54 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css" 2 | import { Figtree } from "next/font/google" 3 | 4 | import Sidebar from "../components/Sidebar" 5 | import SupabaseProvider from "./providers/SupabaseProvider" 6 | import UserProvider from "./providers/UserProvider" 7 | import ModalProvider from "./providers/ModalProvider" 8 | import ToasterProvider from "./providers/ToastProvider" 9 | import getSongsByUserId from "@/actions/getSongsByUserId" 10 | import Player from "@/components/Player" 11 | import getActiveProductsWithPrices from "@/actions/getActiveProductsWithPrices" 12 | 13 | const figtree = Figtree({ subsets: ["latin"] }) 14 | 15 | export const metadata = { 16 | title: "Spotify clone", 17 | description: "Listen to music!", 18 | } 19 | 20 | export const revalidate = 0 21 | 22 | export default async function RootLayout({ children }: { children: React.ReactNode }) { 23 | const userSongs = await getSongsByUserId() 24 | const products = await getActiveProductsWithPrices() 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | {children} 34 | 35 | 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/MediaItem.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Image from "next/image" 4 | 5 | import useLoadImage from "@/hooks/useLoadImage" 6 | import { Song } from "@/types" 7 | import usePlayer from "@/hooks/usePlayer" 8 | 9 | interface MediaItemProps { 10 | data: Song 11 | onClick?: (id: string) => void 12 | } 13 | 14 | const MediaItem: React.FC = ({ data, onClick }) => { 15 | const player = usePlayer() 16 | const imageUrl = useLoadImage(data) 17 | 18 | const handleClick = () => { 19 | if (onClick) { 20 | return onClick(data.id) 21 | } 22 | 23 | return player.setId(data.id) 24 | } 25 | 26 | return ( 27 |
39 |
47 | MediaItem 48 |
49 |
50 |

{data.title}

51 |

By {data.author}

52 |
53 |
54 | ) 55 | } 56 | 57 | export default MediaItem 58 | -------------------------------------------------------------------------------- /app/liked/components/LikedContent.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect } from "react" 4 | import { useRouter } from "next/navigation" 5 | 6 | import { Song } from "@/types" 7 | import { useUser } from "@/hooks/useUser" 8 | import MediaItem from "@/components/MediaItem" 9 | import LikeButton from "@/components/LikeButton" 10 | import useOnPlay from "@/hooks/useOnPlay" 11 | 12 | interface LikedContentProps { 13 | songs: Song[] 14 | } 15 | 16 | const LikedContent: React.FC = ({ songs }) => { 17 | const router = useRouter() 18 | const { isLoading, user } = useUser() 19 | 20 | const onPlay = useOnPlay(songs) 21 | 22 | useEffect(() => { 23 | if (!isLoading && !user) { 24 | router.replace("/") 25 | } 26 | }, [isLoading, user, router]) 27 | 28 | if (songs.length === 0) { 29 | return ( 30 |
38 | No liked songs. 39 |
40 | ) 41 | } 42 | return ( 43 |
44 | {songs.map((song: any) => ( 45 |
46 |
47 | onPlay(id)} data={song} /> 48 |
49 | 50 |
51 | ))} 52 |
53 | ) 54 | } 55 | 56 | export default LikedContent 57 | -------------------------------------------------------------------------------- /app/api/create-checkout-session/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs" 2 | import { cookies } from "next/headers" 3 | import { NextResponse } from "next/server" 4 | 5 | import { stripe } from "@/libs/stripe" 6 | import { getURL } from "@/app/utils/getURL" 7 | import { createOrRetrieveCustomer } from "@/libs/supabaseAdmin" 8 | 9 | export async function POST(request: Request) { 10 | const { price, quantity = 1, metadata = {} } = await request.json() 11 | 12 | try { 13 | const supabase = createRouteHandlerClient({ 14 | cookies, 15 | }) 16 | const { 17 | data: { user }, 18 | } = await supabase.auth.getUser() 19 | 20 | const customer = await createOrRetrieveCustomer({ 21 | uuid: user?.id || "", 22 | email: user?.email || "", 23 | }) 24 | 25 | const session = await stripe.checkout.sessions.create({ 26 | payment_method_types: ["card"], 27 | billing_address_collection: "required", 28 | customer, 29 | line_items: [ 30 | { 31 | price: price.id, 32 | quantity, 33 | }, 34 | ], 35 | mode: "subscription", 36 | allow_promotion_codes: true, 37 | subscription_data: { 38 | trial_from_plan: true, 39 | metadata, 40 | }, 41 | success_url: `${getURL()}/account`, 42 | cancel_url: `${getURL()}/`, 43 | }) 44 | 45 | return NextResponse.json({ sessionId: session.id }) 46 | } catch (err: any) { 47 | console.log(err) 48 | return new NextResponse("Internal Error", { status: 500 }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "workspace", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "update-types": "pnpx supabase gen types typescript --project-id eqhntdpwtnhqzcnpxegh > app/interfaces/types_db.ts" 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-dialog": "^1.0.5", 14 | "@radix-ui/react-slider": "^1.1.2", 15 | "@stripe/stripe-js": "^1.54.2", 16 | "@supabase/auth-helpers-nextjs": "^0.8.3", 17 | "@supabase/auth-helpers-react": "^0.4.2", 18 | "@supabase/auth-ui-react": "^0.4.6", 19 | "@supabase/auth-ui-shared": "^0.1.8", 20 | "@supabase/supabase-js": "^2.38.4", 21 | "@types/node": "20.3.1", 22 | "@types/react": "18.2.12", 23 | "@types/react-dom": "18.2.5", 24 | "@types/uniqid": "^5.3.3", 25 | "autoprefixer": "10.4.14", 26 | "eslint": "8.42.0", 27 | "eslint-config-next": "13.4.5", 28 | "next": "14.0.1-canary.2", 29 | "postcss": "8.4.24", 30 | "query-string": "^8.1.0", 31 | "react": "18.2.0", 32 | "react-dom": "18.2.0", 33 | "react-hook-form": "^7.47.0", 34 | "react-hot-toast": "^2.4.1", 35 | "react-icons": "^4.11.0", 36 | "react-spinners": "^0.13.8", 37 | "stripe": "^12.18.0", 38 | "tailwind-merge": "^1.14.0", 39 | "tailwindcss": "3.3.2", 40 | "typescript": "5.1.3", 41 | "uniqid": "^5.4.0", 42 | "use-sound": "^4.0.1", 43 | "zustand": "^4.4.4" 44 | }, 45 | "devDependencies": { 46 | "supabase": "^1.106.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /components/AuthModal.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useEffect } from "react" 4 | import { Auth } from "@supabase/auth-ui-react" 5 | import { ThemeSupa } from "@supabase/auth-ui-shared" 6 | import { useSessionContext, useSupabaseClient } from "@supabase/auth-helpers-react" 7 | import { useRouter } from "next/navigation" 8 | 9 | import useAuthModal from "@/hooks/useAuthModal" 10 | 11 | import Modal from "./Modal" 12 | import { getURL } from "@/app/utils/getURL" 13 | 14 | const AuthModal = () => { 15 | const { session } = useSessionContext() 16 | const router = useRouter() 17 | const { onClose, isOpen } = useAuthModal() 18 | 19 | const supabaseClient = useSupabaseClient() 20 | 21 | useEffect(() => { 22 | if (session) { 23 | router.refresh() 24 | onClose() 25 | } 26 | }, [session, router, onClose]) 27 | 28 | const onChange = (open: boolean) => { 29 | if (!open) { 30 | onClose() 31 | } 32 | } 33 | 34 | return ( 35 | 36 | 54 | 55 | ) 56 | } 57 | 58 | export default AuthModal 59 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe" 2 | 3 | export interface Song { 4 | id: string 5 | user_id: string 6 | author: string 7 | title: string 8 | song_path: string 9 | image_path: string 10 | } 11 | 12 | export interface Product { 13 | id: string 14 | active?: boolean 15 | name?: string 16 | description?: string 17 | image?: string 18 | metadata?: Stripe.Metadata 19 | } 20 | 21 | export interface Price { 22 | id: string 23 | product_id?: string 24 | active?: boolean 25 | description?: string 26 | unit_amount?: number 27 | currency?: string 28 | type?: Stripe.Price.Type 29 | interval?: Stripe.Price.Recurring.Interval 30 | interval_count?: number 31 | trial_period_days?: number | null 32 | metadata?: Stripe.Metadata 33 | products?: Product 34 | } 35 | 36 | export interface Customer { 37 | id: string 38 | stripe_customer_id?: string 39 | } 40 | 41 | export interface UserDetails { 42 | id: string 43 | first_name: string 44 | last_name: string 45 | full_name?: string 46 | avatar_url?: string 47 | billing_address?: Stripe.Address 48 | payment_method?: Stripe.PaymentMethod[Stripe.PaymentMethod.Type] 49 | } 50 | 51 | export interface ProductWithPrice extends Product { 52 | prices?: Price[] 53 | } 54 | 55 | export interface Subscription { 56 | id: string 57 | user_id: string 58 | status?: Stripe.Subscription.Status 59 | metadata?: Stripe.Metadata 60 | price_id?: string 61 | quantity?: number 62 | cancel_at_period_end?: boolean 63 | created: string 64 | current_period_start: string 65 | current_period_end: string 66 | ended_at?: string 67 | cancel_at?: string 68 | canceled_at?: string 69 | trial_start?: string 70 | trial_end?: string 71 | prices?: Price 72 | } 73 | -------------------------------------------------------------------------------- /components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { usePathname } from "next/navigation" 4 | import { useMemo } from "react" 5 | import { HiHome } from "react-icons/hi" 6 | import { BiSearch } from "react-icons/bi" 7 | import { twMerge } from "tailwind-merge" 8 | 9 | import Box from "./Box" 10 | import SidebarItem from "./SidebarItem" 11 | import Library from "./Library" 12 | import { Song } from "@/types" 13 | import usePlayer from "@/hooks/usePlayer" 14 | 15 | interface SidebarProps { 16 | children: React.ReactNode 17 | songs: Song[] 18 | } 19 | 20 | const Sidebar: React.FC = ({ children, songs }) => { 21 | const pathname = usePathname() 22 | const player = usePlayer() 23 | 24 | const routes = useMemo( 25 | () => [ 26 | { 27 | icon: HiHome, 28 | label: "Home", 29 | active: pathname !== "/search", 30 | href: "/", 31 | }, 32 | { 33 | icon: BiSearch, 34 | label: "Search", 35 | active: pathname === "/search", 36 | href: "/search", 37 | }, 38 | // eslint-disable-next-line react-hooks/exhaustive-deps 39 | ], 40 | [], 41 | ) 42 | 43 | return ( 44 |
45 |
46 | 47 |
48 | {routes.map(item => ( 49 | 50 | ))} 51 |
52 |
53 | 54 | 55 | 56 |
57 |
{children}
58 |
59 | ) 60 | } 61 | 62 | export default Sidebar 63 | -------------------------------------------------------------------------------- /components/Library.tsx: -------------------------------------------------------------------------------- 1 | import { TbPlaylist } from "react-icons/tb" 2 | import { AiOutlinePlus } from "react-icons/ai" 3 | 4 | import useAuthModal from "@/hooks/useAuthModal" 5 | import { useUser } from "@/hooks/useUser" 6 | import useUploadModal from "@/hooks/useUploadModal" 7 | import { Song } from "@/types" 8 | import MediaItem from "./MediaItem" 9 | import useOnPlay from "@/hooks/useOnPlay" 10 | import useSubscribeModal from "@/hooks/useSubscribeModal" 11 | 12 | interface LibraryProps { 13 | songs: Song[] 14 | } 15 | 16 | const Library = ({ songs }: LibraryProps) => { 17 | const subscribeModal = useSubscribeModal() 18 | const authModal = useAuthModal() 19 | const uploadModal = useUploadModal() 20 | const { user, subscription } = useUser() 21 | 22 | const onPlay = useOnPlay(songs) 23 | 24 | const onClick = () => { 25 | if (!user) { 26 | return authModal.onOpen() 27 | } 28 | if (!subscription) { 29 | return subscribeModal.onOpen() 30 | } 31 | return uploadModal.onOpen() 32 | } 33 | 34 | return ( 35 |
36 |
37 |
38 | 39 |

Your libray

40 |
41 | 46 |
47 |
48 | {songs.map(song => ( 49 | onPlay(id)} key={song.id} data={song} /> 50 | ))} 51 |
52 |
53 | ) 54 | } 55 | 56 | export default Library 57 | -------------------------------------------------------------------------------- /components/SongItem.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Image from "next/image" 4 | 5 | import useLoadImage from "@/hooks/useLoadImage" 6 | import { Song } from "@/types" 7 | 8 | import PlayButton from "./PlayButton" 9 | 10 | interface SongItemProps { 11 | data: Song 12 | onClick: (id: string) => void 13 | } 14 | 15 | const SongItem: React.FC = ({ data, onClick }) => { 16 | const imagePath = useLoadImage(data) 17 | 18 | return ( 19 |
onClick(data.id)} 21 | className=" 22 | relative 23 | group 24 | flex 25 | flex-col 26 | items-center 27 | justify-center 28 | rounded-md 29 | overflow-hidden 30 | gap-x-4 31 | bg-neutral-400/5 32 | cursor-pointer 33 | hover:bg-neutral-400/10 34 | transition 35 | p-3 36 | "> 37 |
46 | Image 47 |
48 |
49 |

{data.title}

50 |

58 | By {data.author} 59 |

60 |
61 |
67 | 68 |
69 |
70 | ) 71 | } 72 | 73 | export default SongItem 74 | -------------------------------------------------------------------------------- /app/account/components/AccountContent.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState } from "react" 4 | import { useRouter } from "next/navigation" 5 | 6 | import { useUser } from "@/hooks/useUser" 7 | import Button from "@/components/Button" 8 | import useSubscribeModal from "@/hooks/useSubscribeModal" 9 | import { postData } from "@/libs/helpers" 10 | 11 | const AccountContent = () => { 12 | const router = useRouter() 13 | const subscribeModal = useSubscribeModal() 14 | const { isLoading, subscription, user } = useUser() 15 | 16 | const [loading, setLoading] = useState(false) 17 | 18 | useEffect(() => { 19 | if (!isLoading && !user) { 20 | router.replace("/") 21 | } 22 | }, [isLoading, user, router]) 23 | 24 | const redirectToCustomerPortal = async () => { 25 | setLoading(true) 26 | try { 27 | const { url, error } = await postData({ 28 | url: "/api/create-portal-link", 29 | }) 30 | window.location.assign(url) 31 | } catch (error) { 32 | if (error) return alert((error as Error).message) 33 | } 34 | setLoading(false) 35 | } 36 | 37 | return ( 38 |
39 | {!subscription && ( 40 |
41 |

No active plan.

42 | 45 |
46 | )} 47 | {subscription && ( 48 |
49 |

50 | You are currently on the 51 | {subscription?.prices?.products?.name} 52 | plan. 53 |

54 | 57 |
58 | )} 59 |
60 | ) 61 | } 62 | 63 | export default AccountContent 64 | -------------------------------------------------------------------------------- /components/LikeButton.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState } from "react" 4 | import { AiOutlineHeart, AiFillHeart } from "react-icons/ai" 5 | import { useRouter } from "next/navigation" 6 | import { toast } from "react-hot-toast" 7 | import { useSessionContext } from "@supabase/auth-helpers-react" 8 | 9 | import { useUser } from "@/hooks/useUser" 10 | import useAuthModal from "@/hooks/useAuthModal" 11 | 12 | interface LikeButtonProps { 13 | songId: string 14 | } 15 | 16 | const LikeButton: React.FC = ({ songId }) => { 17 | const router = useRouter() 18 | const { supabaseClient } = useSessionContext() 19 | const authModal = useAuthModal() 20 | const { user } = useUser() 21 | 22 | const [isLiked, setIsLiked] = useState(false) 23 | 24 | useEffect(() => { 25 | if (!user?.id) { 26 | return 27 | } 28 | 29 | const fetchData = async () => { 30 | const { data, error } = await supabaseClient 31 | .from("liked_songs") 32 | .select("*") 33 | .eq("user_id", user.id) 34 | .eq("song_id", songId) 35 | .single() 36 | 37 | if (!error && data) { 38 | setIsLiked(true) 39 | } 40 | } 41 | 42 | fetchData() 43 | }, [songId, supabaseClient, user?.id]) 44 | 45 | const Icon = isLiked ? AiFillHeart : AiOutlineHeart 46 | 47 | const handleLike = async () => { 48 | if (!user) { 49 | return authModal.onOpen() 50 | } 51 | 52 | if (isLiked) { 53 | const { error } = await supabaseClient.from("liked_songs").delete().eq("user_id", user.id).eq("song_id", songId) 54 | 55 | if (error) { 56 | toast.error(error.message) 57 | } else { 58 | setIsLiked(false) 59 | } 60 | } else { 61 | const { error } = await supabaseClient.from("liked_songs").insert({ 62 | song_id: songId, 63 | user_id: user.id, 64 | }) 65 | 66 | if (error) { 67 | toast.error(error.message) 68 | } else { 69 | setIsLiked(true) 70 | toast.success("Success") 71 | } 72 | } 73 | 74 | router.refresh() 75 | } 76 | 77 | return ( 78 | 87 | ) 88 | } 89 | 90 | export default LikeButton 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What inside?
https://19-spotify-clone.vercel.app/ 2 | 3 | ![19_spotify-clone](https://i.imgur.com/YyqXl2t.png) 4 | 5 |
6 |
7 |
8 | 9 | # Clone repository 10 | 11 | ## Step 1.1 - clone repository (variant 1) 12 | 13 | ![alt text](https://i.imgur.com/9KSgjaN.png) 14 | 15 | ## or Step 1.1 - clone repository (variant 2) 16 | 17 | ``` 18 | git clone https://github.com/nicitaacom/19_spotify-clone 19 | ``` 20 | 21 | ## Step 1.2 - install deps 22 | 23 | ``` 24 | pnpm i 25 | ``` 26 | 27 | ## Step 1.3 - run project 28 | 29 | ``` 30 | pnpm dev 31 | ``` 32 | 33 |
34 |
35 |
36 | 37 | ## Step 2 - setup .env 38 | 39 | ### 2.1 - github 40 | 41 | ![Go to settings](https://i.imgur.com/vnG4aMh.png) 42 | 43 | ### 2.2 - github 44 | 45 | ![Go to developer settings](https://i.imgur.com/eodZM9p.png) 46 | 47 | ### 2.3 - github 48 | 49 | ![OAuth Apps](https://i.imgur.com/yjeGtKv.png) 50 | 51 | ### 2.4 - github 52 | 53 | ![New OAuth App](https://i.imgur.com/QXuo0kE.png) 54 | 55 | ### 2.5 - github 56 | 57 | ![Auth url](https://i.imgur.com/MKmuYnA.png) 58 | 59 | ### 2.6 - github 60 | 61 | ![Copy env values](https://i.imgur.com/SIkWyeE.png) 62 | 63 | ### 2.7 - github 64 | 65 | ![Paste env values](https://i.imgur.com/yoevhr7.png) 66 | 67 |
68 | 69 | ### 2.8 - supabase (Login in supabase - https://app.supabase.com/sign-in) 70 | 71 | ![Login in supabase](https://i.imgur.com/zxJFahy.png) 72 | 73 | ### 2.9 - supabase 74 | 75 | ![Click new project](https://i.imgur.com/9YZGJ8j.png) 76 | 77 | ### 2.10 - supabase 78 | 79 | ![Enter aer](https://i.imgur.com/zxJFahy.png) 80 | 81 | ### 2.11 - supabase 82 | 83 | ![Set up project](https://i.imgur.com/0xIb866.png) 84 | 85 | ### 2.12 - supabase 86 | 87 | ![Copy env](https://i.imgur.com/592li1Z.png) 88 | 89 | ### 2.13 - supabase 90 | 91 | ![Paste env](https://i.imgur.com/Qpvso8o.png) 92 | 93 |
94 | 95 | ### 2.14 - stripe 96 | 97 | ![create new project](https://i.imgur.com/q89qoOS.png) 98 | 99 | ### 2.14 - stripe 100 | 101 | ![Choose name for project](https://i.imgur.com/1A0I7t3.png) 102 | 103 | ### 2.14 - stripe 104 | 105 | ![Copy public key and secret key](https://i.imgur.com/JZsT4Na.png) 106 | 107 | ### 2.15 - stripe 108 | 109 | ![Paste public and secret key](https://i.imgur.com/Ja1Iwuo.png) 110 | 111 | ### 2.16 - stripe (use this guide to paste your webhook https://youtu.be/2aeMRB8LL4o?t=20951) 112 | -------------------------------------------------------------------------------- /hooks/useUser.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, createContext, useContext } from "react" 2 | import { useUser as useSupaUser, useSessionContext, User } from "@supabase/auth-helpers-react" 3 | 4 | import { UserDetails, Subscription } from "@/types" 5 | 6 | type UserContextType = { 7 | accessToken: string | null 8 | user: User | null 9 | userDetails: UserDetails | null 10 | isLoading: boolean 11 | subscription: Subscription | null 12 | } 13 | 14 | export const UserContext = createContext(undefined) 15 | 16 | export interface Props { 17 | [propName: string]: any 18 | } 19 | 20 | export const MyUserContextProvider = (props: Props) => { 21 | const { session, isLoading: isLoadingUser, supabaseClient: supabase } = useSessionContext() 22 | const user = useSupaUser() 23 | const accessToken = session?.access_token ?? null 24 | const [isLoadingData, setIsloadingData] = useState(false) 25 | const [userDetails, setUserDetails] = useState(null) 26 | const [subscription, setSubscription] = useState(null) 27 | 28 | const getUserDetails = () => supabase.from("users").select("*").single() 29 | const getSubscription = () => 30 | supabase.from("subscriptions").select("*, prices(*, products(*))").in("status", ["trialing", "active"]).single() 31 | 32 | useEffect(() => { 33 | if (user && !isLoadingData && !userDetails && !subscription) { 34 | setIsloadingData(true) 35 | Promise.allSettled([getUserDetails(), getSubscription()]).then(results => { 36 | const userDetailsPromise = results[0] 37 | const subscriptionPromise = results[1] 38 | 39 | if (userDetailsPromise.status === "fulfilled") setUserDetails(userDetailsPromise.value.data as UserDetails) 40 | 41 | if (subscriptionPromise.status === "fulfilled") setSubscription(subscriptionPromise.value.data as Subscription) 42 | 43 | setIsloadingData(false) 44 | }) 45 | } else if (!user && !isLoadingUser && !isLoadingData) { 46 | setUserDetails(null) 47 | setSubscription(null) 48 | } 49 | // eslint-disable-next-line react-hooks/exhaustive-deps 50 | }, [user, isLoadingUser]) 51 | 52 | const value = { 53 | accessToken, 54 | user, 55 | userDetails, 56 | isLoading: isLoadingUser || isLoadingData, 57 | subscription, 58 | } 59 | 60 | return 61 | } 62 | 63 | export const useUser = () => { 64 | const context = useContext(UserContext) 65 | if (context === undefined) { 66 | throw new Error(`useUser must be used within a MyUserContextProvider.`) 67 | } 68 | return context 69 | } 70 | -------------------------------------------------------------------------------- /components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import * as Dialog from "@radix-ui/react-dialog" 2 | import { IoMdClose } from "react-icons/io" 3 | 4 | interface ModalProps { 5 | isOpen: boolean 6 | onChange: (open: boolean) => void 7 | title: string 8 | description: string 9 | children: React.ReactNode 10 | } 11 | 12 | const Modal: React.FC = ({ isOpen, onChange, title, description, children }) => { 13 | return ( 14 | 15 | 16 | 24 | 46 | 53 | {title} 54 | 55 | 62 | {description} 63 | 64 |
{children}
65 | 66 | 85 | 86 |
87 |
88 |
89 | ) 90 | } 91 | 92 | export default Modal 93 | -------------------------------------------------------------------------------- /app/api/webhooks/route.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe" 2 | import { NextResponse } from "next/server" 3 | import { headers } from "next/headers" 4 | 5 | import { stripe } from "@/libs/stripe" 6 | import { upsertProductRecord, upsertPriceRecord, manageSubscriptionStatusChange } from "@/libs/supabaseAdmin" 7 | 8 | const relevantEvents = new Set([ 9 | "product.created", 10 | "product.updated", 11 | "price.created", 12 | "price.updated", 13 | "checkout.session.completed", 14 | "customer.subscription.created", 15 | "customer.subscription.updated", 16 | "customer.subscription.deleted", 17 | ]) 18 | 19 | export async function POST(request: Request) { 20 | const body = await request.text() 21 | const sig = headers().get("Stripe-Signature") 22 | 23 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET_LIVE ?? process.env.STRIPE_WEBHOOK_SECRET 24 | let event: Stripe.Event 25 | 26 | try { 27 | if (!sig || !webhookSecret) return 28 | event = stripe.webhooks.constructEvent(body, sig, webhookSecret) 29 | } catch (err: any) { 30 | console.log(`❌ Error message: ${err.message}`) 31 | return new NextResponse(`Webhook Error: ${err.message}`, { status: 400 }) 32 | } 33 | 34 | console.log(34, "webhook triggered") 35 | 36 | if (relevantEvents.has(event.type)) { 37 | try { 38 | switch (event.type) { 39 | case "product.created": 40 | case "product.updated": 41 | await upsertProductRecord(event.data.object as Stripe.Product) 42 | break 43 | case "price.created": 44 | case "price.updated": 45 | await upsertPriceRecord(event.data.object as Stripe.Price) 46 | break 47 | case "customer.subscription.created": 48 | case "customer.subscription.updated": 49 | case "customer.subscription.deleted": 50 | const subscription = event.data.object as Stripe.Subscription 51 | await manageSubscriptionStatusChange( 52 | subscription.id, 53 | subscription.customer as string, 54 | event.type === "customer.subscription.created" 55 | ) 56 | break 57 | case "checkout.session.completed": 58 | const checkoutSession = event.data.object as Stripe.Checkout.Session 59 | if (checkoutSession.mode === "subscription") { 60 | const subscriptionId = checkoutSession.subscription 61 | await manageSubscriptionStatusChange(subscriptionId as string, checkoutSession.customer as string, true) 62 | } 63 | break 64 | default: 65 | throw new Error("Unhandled relevant event!") 66 | } 67 | } catch (error) { 68 | console.log(error) 69 | return new NextResponse('Webhook error: "Webhook handler failed. View logs."', { status: 400 }) 70 | } 71 | } 72 | 73 | return NextResponse.json({ received: true }, { status: 200 }) 74 | } 75 | -------------------------------------------------------------------------------- /components/SubscribeModal.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useState } from "react" 4 | import { toast } from "react-hot-toast" 5 | 6 | import useSubscribeModal from "@/hooks/useSubscribeModal" 7 | import { useUser } from "@/hooks/useUser" 8 | import { postData } from "@/libs/helpers" 9 | import { getStripe } from "@/libs/stripeClient" 10 | import { Price, ProductWithPrice } from "@/types" 11 | 12 | import Modal from "./Modal" 13 | import Button from "./Button" 14 | 15 | interface SubscribeModalProps { 16 | products: ProductWithPrice[] 17 | } 18 | 19 | const formatPrice = (price: Price) => { 20 | const priceString = new Intl.NumberFormat("en-US", { 21 | style: "currency", 22 | currency: price.currency, 23 | minimumFractionDigits: 0, 24 | }).format((price?.unit_amount || 0) / 100) 25 | 26 | return priceString 27 | } 28 | 29 | const SubscribeModal: React.FC = ({ products }) => { 30 | const subscribeModal = useSubscribeModal() 31 | const { user, isLoading, subscription } = useUser() 32 | 33 | const [priceIdLoading, setPriceIdLoading] = useState() 34 | 35 | const onChange = (open: boolean) => { 36 | if (!open) { 37 | subscribeModal.onClose() 38 | } 39 | } 40 | 41 | const handleCheckout = async (price: Price) => { 42 | setPriceIdLoading(price.id) 43 | if (!user) { 44 | setPriceIdLoading(undefined) 45 | return toast.error("Must be logged in") 46 | } 47 | 48 | if (subscription) { 49 | setPriceIdLoading(undefined) 50 | return toast("Already subscribed") 51 | } 52 | 53 | try { 54 | const { sessionId } = await postData({ 55 | url: "/api/create-checkout-session", 56 | data: { price }, 57 | }) 58 | 59 | const stripe = await getStripe() 60 | stripe?.redirectToCheckout({ sessionId }) 61 | } catch (error) { 62 | return toast.error((error as Error)?.message) 63 | } finally { 64 | setPriceIdLoading(undefined) 65 | } 66 | } 67 | 68 | let content =
No products available.
69 | 70 | if (products.length) { 71 | content = ( 72 |
73 | {products.map(product => { 74 | if (!product.prices?.length) { 75 | return
No prices available
76 | } 77 | 78 | return product.prices.map(price => ( 79 | 86 | )) 87 | })} 88 |
89 | ) 90 | } 91 | 92 | if (subscription) { 93 | content =
Already subscribed.
94 | } 95 | 96 | return ( 97 | 102 | {content} 103 | 104 | ) 105 | } 106 | 107 | export default SubscribeModal 108 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useRouter } from "next/navigation" 4 | import { twMerge } from "tailwind-merge" 5 | import { useSupabaseClient } from "@supabase/auth-helpers-react" 6 | import { RxCaretLeft, RxCaretRight } from "react-icons/rx" 7 | import { HiHome } from "react-icons/hi" 8 | import { BiSearch } from "react-icons/bi" 9 | import { FaUserAlt } from "react-icons/fa" 10 | import toast from "react-hot-toast" 11 | 12 | import Button from "./Button" 13 | import useAuthModal from "@/hooks/useAuthModal" 14 | import { useUser } from "@/hooks/useUser" 15 | import usePlayer from "@/hooks/usePlayer" 16 | 17 | interface HeaderProps { 18 | children: React.ReactNode 19 | className?: string 20 | } 21 | 22 | const Header: React.FC = ({ children, className }) => { 23 | const player = usePlayer() 24 | const authModal = useAuthModal() 25 | 26 | const router = useRouter() 27 | 28 | const supabaseClient = useSupabaseClient() 29 | const { user } = useUser() 30 | 31 | const handleLogout = async () => { 32 | const { error } = await supabaseClient.auth.signOut() 33 | player.reset() 34 | router.refresh() 35 | 36 | if (error) { 37 | toast.error(error.message) 38 | } else { 39 | toast.success("Logged out!") 40 | } 41 | } 42 | 43 | return ( 44 |
45 |
46 |
47 | 52 | 57 |
58 |
59 | 62 | 65 |
66 |
67 | {user ? ( 68 |
69 | 70 | 73 |
74 | ) : ( 75 | <> 76 |
77 | 80 |
81 |
82 | 85 |
86 | 87 | )} 88 |
89 |
90 | {children} 91 |
92 | ) 93 | } 94 | 95 | export default Header 96 | -------------------------------------------------------------------------------- /components/UploadModal.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import uniqid from "uniqid" 4 | import React, { useState } from "react" 5 | import { useSupabaseClient } from "@supabase/auth-helpers-react" 6 | import { FieldValues, SubmitHandler, useForm } from "react-hook-form" 7 | import { toast } from "react-hot-toast" 8 | import { useRouter } from "next/navigation" 9 | 10 | import useUploadModal from "@/hooks/useUploadModal" 11 | import { useUser } from "@/hooks/useUser" 12 | 13 | import Modal from "./Modal" 14 | import Input from "./Input" 15 | import Button from "./Button" 16 | 17 | const UploadModal = () => { 18 | const [isLoading, setIsLoading] = useState(false) 19 | 20 | const uploadModal = useUploadModal() 21 | const supabaseClient = useSupabaseClient() 22 | const { user } = useUser() 23 | const router = useRouter() 24 | 25 | const { register, handleSubmit, reset } = useForm({ 26 | defaultValues: { 27 | author: "", 28 | title: "", 29 | song: null, 30 | image: null, 31 | }, 32 | }) 33 | 34 | const onChange = (open: boolean) => { 35 | if (!open) { 36 | reset() 37 | uploadModal.onClose() 38 | } 39 | } 40 | 41 | const onSubmit: SubmitHandler = async values => { 42 | try { 43 | setIsLoading(true) 44 | 45 | const imageFile = values.image?.[0] 46 | const songFile = values.song?.[0] 47 | 48 | if (!imageFile || !songFile || !user) { 49 | toast.error("Missing fields") 50 | return 51 | } 52 | 53 | const uniqueID = uniqid() 54 | 55 | // Upload song 56 | const { data: songData, error: songError } = await supabaseClient.storage 57 | .from("songs") 58 | .upload(`song-${values.title}-${uniqueID}`, songFile, { 59 | cacheControl: "3600", 60 | upsert: false, 61 | }) 62 | 63 | if (songError) { 64 | setIsLoading(false) 65 | return toast.error("Failed song upload") 66 | } 67 | 68 | // Upload image 69 | const { data: imageData, error: imageError } = await supabaseClient.storage 70 | .from("images") 71 | .upload(`image-${values.title}-${uniqueID}`, imageFile, { 72 | cacheControl: "3600", 73 | upsert: false, 74 | }) 75 | 76 | if (imageError) { 77 | setIsLoading(false) 78 | return toast.error("Failed image upload") 79 | } 80 | 81 | // Create record 82 | const { error: supabaseError } = await supabaseClient.from("songs").insert({ 83 | user_id: user.id, 84 | title: values.title, 85 | author: values.author, 86 | image_path: imageData.path, 87 | song_path: songData.path, 88 | }) 89 | 90 | if (supabaseError) { 91 | return toast.error(supabaseError.message) 92 | } 93 | 94 | router.refresh() 95 | setIsLoading(false) 96 | toast.success("Song created!") 97 | reset() 98 | uploadModal.onClose() 99 | } catch (error) { 100 | toast.error("Something went wrong") 101 | } finally { 102 | setIsLoading(false) 103 | } 104 | } 105 | 106 | return ( 107 | 108 |
109 | 110 | 111 |
112 |
Select a song file
113 | 121 |
122 |
123 |
Select an image
124 | 132 |
133 | 136 |
137 |
138 | ) 139 | } 140 | 141 | export default UploadModal 142 | -------------------------------------------------------------------------------- /components/PlayerContent.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import useSound from "use-sound" 4 | import { useEffect, useState } from "react" 5 | import { BsPauseFill, BsPlayFill } from "react-icons/bs" 6 | import { HiSpeakerWave, HiSpeakerXMark } from "react-icons/hi2" 7 | import { AiFillStepBackward, AiFillStepForward } from "react-icons/ai" 8 | 9 | import { Song } from "@/types" 10 | import usePlayer from "@/hooks/usePlayer" 11 | 12 | import LikeButton from "./LikeButton" 13 | import MediaItem from "./MediaItem" 14 | import Slider from "./Slider" 15 | 16 | interface PlayerContentProps { 17 | song: Song 18 | songUrl: string 19 | } 20 | 21 | const PlayerContent: React.FC = ({ song, songUrl }) => { 22 | const player = usePlayer() 23 | const [volume, setVolume] = useState(1) 24 | const [isPlaying, setIsPlaying] = useState(false) 25 | 26 | const Icon = isPlaying ? BsPauseFill : BsPlayFill 27 | const VolumeIcon = volume === 0 ? HiSpeakerXMark : HiSpeakerWave 28 | 29 | const onPlayNext = () => { 30 | if (player.ids.length === 0) { 31 | return 32 | } 33 | 34 | const currentIndex = player.ids.findIndex(id => id === player.activeId) 35 | const nextSong = player.ids[currentIndex + 1] 36 | 37 | if (!nextSong) { 38 | return player.setId(player.ids[0]) 39 | } 40 | 41 | player.setId(nextSong) 42 | } 43 | 44 | const onPlayPrevious = () => { 45 | if (player.ids.length === 0) { 46 | return 47 | } 48 | 49 | const currentIndex = player.ids.findIndex(id => id === player.activeId) 50 | const previousSong = player.ids[currentIndex - 1] 51 | 52 | if (!previousSong) { 53 | return player.setId(player.ids[player.ids.length - 1]) 54 | } 55 | 56 | player.setId(previousSong) 57 | } 58 | 59 | const [play, { pause, sound }] = useSound(songUrl, { 60 | volume: volume, 61 | onplay: () => setIsPlaying(true), 62 | onend: () => { 63 | setIsPlaying(false) 64 | onPlayNext() 65 | }, 66 | onpause: () => setIsPlaying(false), 67 | format: ["mp3"], 68 | }) 69 | 70 | useEffect(() => { 71 | sound?.play() 72 | 73 | return () => { 74 | sound?.unload() 75 | } 76 | }, [sound]) 77 | 78 | const handlePlay = () => { 79 | if (!isPlaying) { 80 | play() 81 | } else { 82 | pause() 83 | } 84 | } 85 | 86 | const toggleMute = () => { 87 | if (volume === 0) { 88 | setVolume(1) 89 | } else { 90 | setVolume(0) 91 | } 92 | } 93 | 94 | return ( 95 |
96 |
97 |
98 | 99 | 100 |
101 |
102 | 103 |
112 |
125 | 126 |
127 |
128 | 129 |
140 | 150 |
163 | 164 |
165 | 175 |
176 | 177 |
178 |
179 | 180 | setVolume(value)} /> 181 |
182 |
183 |
184 | ) 185 | } 186 | 187 | export default PlayerContent 188 | -------------------------------------------------------------------------------- /libs/supabaseAdmin.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe" 2 | import { createClient } from "@supabase/supabase-js" 3 | 4 | import { Database } from "@/types_db" 5 | import { Price, Product } from "@/types" 6 | 7 | import { stripe } from "./stripe" 8 | import { toDateTime } from "./helpers" 9 | 10 | export const supabaseAdmin = createClient( 11 | process.env.NEXT_PUBLIC_SUPABASE_URL || "", 12 | process.env.SUPABASE_SERVICE_ROLE_KEY || "", 13 | ) 14 | 15 | const upsertProductRecord = async (product: Stripe.Product) => { 16 | const productData: Product = { 17 | id: product.id, 18 | active: product.active, 19 | name: product.name, 20 | description: product.description ?? undefined, 21 | image: product.images?.[0] ?? null, 22 | metadata: product.metadata, 23 | } 24 | 25 | const { error } = await supabaseAdmin.from("products").upsert([productData]) 26 | if (error) throw error 27 | console.log(`Product inserted/updated: ${product.id}`) 28 | } 29 | 30 | const upsertPriceRecord = async (price: Stripe.Price) => { 31 | const priceData: Price = { 32 | id: price.id, 33 | product_id: typeof price.product === "string" ? price.product : "", 34 | active: price.active, 35 | currency: price.currency, 36 | description: price.nickname ?? undefined, 37 | type: price.type, 38 | unit_amount: price.unit_amount ?? undefined, 39 | interval: price.recurring?.interval, 40 | interval_count: price.recurring?.interval_count, 41 | trial_period_days: price.recurring?.trial_period_days, 42 | metadata: price.metadata, 43 | } 44 | 45 | const { error } = await supabaseAdmin.from("prices").upsert([priceData]) 46 | if (error) throw error 47 | console.log(`Price inserted/updated: ${price.id}`) 48 | } 49 | 50 | const createOrRetrieveCustomer = async ({ email, uuid }: { email: string; uuid: string }) => { 51 | const { data, error } = await supabaseAdmin.from("customers").select("stripe_customer_id").eq("id", uuid).single() 52 | if (error || !data?.stripe_customer_id) { 53 | const customerData: { metadata: { supabaseUUID: string }; email?: string } = { 54 | metadata: { 55 | supabaseUUID: uuid, 56 | }, 57 | } 58 | if (email) customerData.email = email 59 | const customer = await stripe.customers.create(customerData) 60 | const { error: supabaseError } = await supabaseAdmin 61 | .from("customers") 62 | .insert([{ id: uuid, stripe_customer_id: customer.id }]) 63 | if (supabaseError) throw supabaseError 64 | console.log(`New customer created and inserted for ${uuid}.`) 65 | return customer.id 66 | } 67 | return data.stripe_customer_id 68 | } 69 | 70 | const copyBillingDetailsToCustomer = async (uuid: string, payment_method: Stripe.PaymentMethod) => { 71 | //Todo: check this assertion 72 | const customer = payment_method.customer as string 73 | const { name, phone, address } = payment_method.billing_details 74 | if (!name || !phone || !address) return 75 | //@ts-ignore 76 | await stripe.customers.update(customer, { name, phone, address }) 77 | const { error } = await supabaseAdmin 78 | .from("users") 79 | .update({ 80 | billing_address: { ...address }, 81 | payment_method: { ...payment_method[payment_method.type] }, 82 | }) 83 | .eq("id", uuid) 84 | if (error) throw error 85 | } 86 | 87 | const manageSubscriptionStatusChange = async (subscriptionId: string, customerId: string, createAction = false) => { 88 | // Get customer's UUID from mapping table. 89 | const { data: customerData, error: noCustomerError } = await supabaseAdmin 90 | .from("customers") 91 | .select("id") 92 | .eq("stripe_customer_id", customerId) 93 | .single() 94 | if (noCustomerError) throw noCustomerError 95 | 96 | const { id: uuid } = customerData! 97 | 98 | const subscription = await stripe.subscriptions.retrieve(subscriptionId, { 99 | expand: ["default_payment_method"], 100 | }) 101 | // Upsert the latest status of the subscription object. 102 | const subscriptionData: Database["public"]["Tables"]["subscriptions"]["Insert"] = { 103 | id: subscription.id, 104 | user_id: uuid, 105 | metadata: subscription.metadata, 106 | // @ts-ignore 107 | status: subscription.status, 108 | price_id: subscription.items.data[0].price.id, 109 | //TODO check quantity on subscription 110 | // @ts-ignore 111 | quantity: subscription.quantity, 112 | cancel_at_period_end: subscription.cancel_at_period_end, 113 | cancel_at: subscription.cancel_at ? toDateTime(subscription.cancel_at).toISOString() : null, 114 | canceled_at: subscription.canceled_at ? toDateTime(subscription.canceled_at).toISOString() : null, 115 | current_period_start: toDateTime(subscription.current_period_start).toISOString(), 116 | current_period_end: toDateTime(subscription.current_period_end).toISOString(), 117 | created: toDateTime(subscription.created).toISOString(), 118 | ended_at: subscription.ended_at ? toDateTime(subscription.ended_at).toISOString() : null, 119 | trial_start: subscription.trial_start ? toDateTime(subscription.trial_start).toISOString() : null, 120 | trial_end: subscription.trial_end ? toDateTime(subscription.trial_end).toISOString() : null, 121 | } 122 | 123 | const { error } = await supabaseAdmin.from("subscriptions").upsert([subscriptionData]) 124 | if (error) throw error 125 | console.log(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`) 126 | 127 | // For a new subscription copy the billing details to the customer object. 128 | // NOTE: This is a costly operation and should happen at the very end. 129 | if (createAction && subscription.default_payment_method && uuid) 130 | //@ts-ignore 131 | await copyBillingDetailsToCustomer(uuid, subscription.default_payment_method as Stripe.PaymentMethod) 132 | } 133 | 134 | export { upsertProductRecord, upsertPriceRecord, createOrRetrieveCustomer, manageSubscriptionStatusChange } 135 | -------------------------------------------------------------------------------- /types_db.ts: -------------------------------------------------------------------------------- 1 | export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[] 2 | 3 | export interface Database { 4 | public: { 5 | Tables: { 6 | customers: { 7 | Row: { 8 | id: string 9 | stripe_customer_id: string | null 10 | } 11 | Insert: { 12 | id: string 13 | stripe_customer_id?: string | null 14 | } 15 | Update: { 16 | id?: string 17 | stripe_customer_id?: string | null 18 | } 19 | Relationships: [ 20 | { 21 | foreignKeyName: "customers_id_fkey" 22 | columns: ["id"] 23 | referencedRelation: "users" 24 | referencedColumns: ["id"] 25 | }, 26 | ] 27 | } 28 | liked_songs: { 29 | Row: { 30 | created_at: string 31 | song_id: number 32 | user_id: string 33 | } 34 | Insert: { 35 | created_at?: string 36 | song_id: number 37 | user_id: string 38 | } 39 | Update: { 40 | created_at?: string 41 | song_id?: number 42 | user_id?: string 43 | } 44 | Relationships: [ 45 | { 46 | foreignKeyName: "liked_songs_song_id_fkey" 47 | columns: ["song_id"] 48 | referencedRelation: "songs" 49 | referencedColumns: ["id"] 50 | }, 51 | { 52 | foreignKeyName: "liked_songs_user_id_fkey" 53 | columns: ["user_id"] 54 | referencedRelation: "users" 55 | referencedColumns: ["id"] 56 | }, 57 | ] 58 | } 59 | prices: { 60 | Row: { 61 | active: boolean | null 62 | currency: string | null 63 | description: string | null 64 | id: string 65 | interval: Database["public"]["Enums"]["pricing_plan_interval"] | null 66 | interval_count: number | null 67 | metadata: Json | null 68 | product_id: string | null 69 | trial_period_days: number | null 70 | type: Database["public"]["Enums"]["pricing_type"] | null 71 | unit_amount: number | null 72 | } 73 | Insert: { 74 | active?: boolean | null 75 | currency?: string | null 76 | description?: string | null 77 | id: string 78 | interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null 79 | interval_count?: number | null 80 | metadata?: Json | null 81 | product_id?: string | null 82 | trial_period_days?: number | null 83 | type?: Database["public"]["Enums"]["pricing_type"] | null 84 | unit_amount?: number | null 85 | } 86 | Update: { 87 | active?: boolean | null 88 | currency?: string | null 89 | description?: string | null 90 | id?: string 91 | interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null 92 | interval_count?: number | null 93 | metadata?: Json | null 94 | product_id?: string | null 95 | trial_period_days?: number | null 96 | type?: Database["public"]["Enums"]["pricing_type"] | null 97 | unit_amount?: number | null 98 | } 99 | Relationships: [ 100 | { 101 | foreignKeyName: "prices_product_id_fkey" 102 | columns: ["product_id"] 103 | referencedRelation: "products" 104 | referencedColumns: ["id"] 105 | }, 106 | ] 107 | } 108 | products: { 109 | Row: { 110 | active: boolean | null 111 | description: string | null 112 | id: string 113 | image: string | null 114 | metadata: Json | null 115 | name: string | null 116 | } 117 | Insert: { 118 | active?: boolean | null 119 | description?: string | null 120 | id: string 121 | image?: string | null 122 | metadata?: Json | null 123 | name?: string | null 124 | } 125 | Update: { 126 | active?: boolean | null 127 | description?: string | null 128 | id?: string 129 | image?: string | null 130 | metadata?: Json | null 131 | name?: string | null 132 | } 133 | Relationships: [] 134 | } 135 | songs: { 136 | Row: { 137 | author: string | null 138 | created_at: string 139 | id: number 140 | image_path: string | null 141 | song_path: string | null 142 | title: string | null 143 | user_id: string | null 144 | } 145 | Insert: { 146 | author?: string | null 147 | created_at?: string 148 | id?: number 149 | image_path?: string | null 150 | song_path?: string | null 151 | title?: string | null 152 | user_id?: string | null 153 | } 154 | Update: { 155 | author?: string | null 156 | created_at?: string 157 | id?: number 158 | image_path?: string | null 159 | song_path?: string | null 160 | title?: string | null 161 | user_id?: string | null 162 | } 163 | Relationships: [ 164 | { 165 | foreignKeyName: "songs_user_id_fkey" 166 | columns: ["user_id"] 167 | referencedRelation: "users" 168 | referencedColumns: ["id"] 169 | }, 170 | ] 171 | } 172 | subscriptions: { 173 | Row: { 174 | cancel_at: string | null 175 | cancel_at_period_end: boolean | null 176 | canceled_at: string | null 177 | created: string 178 | current_period_end: string 179 | current_period_start: string 180 | ended_at: string | null 181 | id: string 182 | metadata: Json | null 183 | price_id: string | null 184 | quantity: number | null 185 | status: Database["public"]["Enums"]["subscription_status"] | null 186 | trial_end: string | null 187 | trial_start: string | null 188 | user_id: string 189 | } 190 | Insert: { 191 | cancel_at?: string | null 192 | cancel_at_period_end?: boolean | null 193 | canceled_at?: string | null 194 | created?: string 195 | current_period_end?: string 196 | current_period_start?: string 197 | ended_at?: string | null 198 | id: string 199 | metadata?: Json | null 200 | price_id?: string | null 201 | quantity?: number | null 202 | status?: Database["public"]["Enums"]["subscription_status"] | null 203 | trial_end?: string | null 204 | trial_start?: string | null 205 | user_id: string 206 | } 207 | Update: { 208 | cancel_at?: string | null 209 | cancel_at_period_end?: boolean | null 210 | canceled_at?: string | null 211 | created?: string 212 | current_period_end?: string 213 | current_period_start?: string 214 | ended_at?: string | null 215 | id?: string 216 | metadata?: Json | null 217 | price_id?: string | null 218 | quantity?: number | null 219 | status?: Database["public"]["Enums"]["subscription_status"] | null 220 | trial_end?: string | null 221 | trial_start?: string | null 222 | user_id?: string 223 | } 224 | Relationships: [ 225 | { 226 | foreignKeyName: "subscriptions_price_id_fkey" 227 | columns: ["price_id"] 228 | referencedRelation: "prices" 229 | referencedColumns: ["id"] 230 | }, 231 | { 232 | foreignKeyName: "subscriptions_user_id_fkey" 233 | columns: ["user_id"] 234 | referencedRelation: "users" 235 | referencedColumns: ["id"] 236 | }, 237 | ] 238 | } 239 | users: { 240 | Row: { 241 | avatar_url: string | null 242 | billing_address: Json | null 243 | full_name: string | null 244 | id: string 245 | payment_method: Json | null 246 | } 247 | Insert: { 248 | avatar_url?: string | null 249 | billing_address?: Json | null 250 | full_name?: string | null 251 | id: string 252 | payment_method?: Json | null 253 | } 254 | Update: { 255 | avatar_url?: string | null 256 | billing_address?: Json | null 257 | full_name?: string | null 258 | id?: string 259 | payment_method?: Json | null 260 | } 261 | Relationships: [ 262 | { 263 | foreignKeyName: "users_id_fkey" 264 | columns: ["id"] 265 | referencedRelation: "users" 266 | referencedColumns: ["id"] 267 | }, 268 | ] 269 | } 270 | } 271 | Views: { 272 | [_ in never]: never 273 | } 274 | Functions: { 275 | [_ in never]: never 276 | } 277 | Enums: { 278 | pricing_plan_interval: "day" | "week" | "month" | "year" 279 | pricing_type: "one_time" | "recurring" 280 | subscription_status: 281 | | "trialing" 282 | | "active" 283 | | "canceled" 284 | | "incomplete" 285 | | "incomplete_expired" 286 | | "past_due" 287 | | "unpaid" 288 | } 289 | CompositeTypes: { 290 | [_ in never]: never 291 | } 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /app/interfaces/types_db.ts: -------------------------------------------------------------------------------- 1 | export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[] 2 | 3 | export type Database = { 4 | public: { 5 | Tables: { 6 | customers: { 7 | Row: { 8 | id: string 9 | stripe_customer_id: string | null 10 | } 11 | Insert: { 12 | id: string 13 | stripe_customer_id?: string | null 14 | } 15 | Update: { 16 | id?: string 17 | stripe_customer_id?: string | null 18 | } 19 | Relationships: [ 20 | { 21 | foreignKeyName: "customers_id_fkey" 22 | columns: ["id"] 23 | isOneToOne: true 24 | referencedRelation: "users" 25 | referencedColumns: ["id"] 26 | }, 27 | ] 28 | } 29 | liked_songs: { 30 | Row: { 31 | created_at: string 32 | song_id: number 33 | user_id: string 34 | } 35 | Insert: { 36 | created_at?: string 37 | song_id: number 38 | user_id: string 39 | } 40 | Update: { 41 | created_at?: string 42 | song_id?: number 43 | user_id?: string 44 | } 45 | Relationships: [ 46 | { 47 | foreignKeyName: "liked_songs_song_id_fkey" 48 | columns: ["song_id"] 49 | isOneToOne: false 50 | referencedRelation: "songs" 51 | referencedColumns: ["id"] 52 | }, 53 | { 54 | foreignKeyName: "liked_songs_user_id_fkey" 55 | columns: ["user_id"] 56 | isOneToOne: false 57 | referencedRelation: "users" 58 | referencedColumns: ["id"] 59 | }, 60 | ] 61 | } 62 | prices: { 63 | Row: { 64 | active: boolean | null 65 | currency: string | null 66 | description: string | null 67 | id: string 68 | interval: Database["public"]["Enums"]["pricing_plan_interval"] | null 69 | interval_count: number | null 70 | metadata: Json | null 71 | product_id: string | null 72 | trial_period_days: number | null 73 | type: Database["public"]["Enums"]["pricing_type"] | null 74 | unit_amount: number | null 75 | } 76 | Insert: { 77 | active?: boolean | null 78 | currency?: string | null 79 | description?: string | null 80 | id: string 81 | interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null 82 | interval_count?: number | null 83 | metadata?: Json | null 84 | product_id?: string | null 85 | trial_period_days?: number | null 86 | type?: Database["public"]["Enums"]["pricing_type"] | null 87 | unit_amount?: number | null 88 | } 89 | Update: { 90 | active?: boolean | null 91 | currency?: string | null 92 | description?: string | null 93 | id?: string 94 | interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null 95 | interval_count?: number | null 96 | metadata?: Json | null 97 | product_id?: string | null 98 | trial_period_days?: number | null 99 | type?: Database["public"]["Enums"]["pricing_type"] | null 100 | unit_amount?: number | null 101 | } 102 | Relationships: [ 103 | { 104 | foreignKeyName: "prices_product_id_fkey" 105 | columns: ["product_id"] 106 | isOneToOne: false 107 | referencedRelation: "products" 108 | referencedColumns: ["id"] 109 | }, 110 | ] 111 | } 112 | products: { 113 | Row: { 114 | active: boolean | null 115 | description: string | null 116 | id: string 117 | image: string | null 118 | metadata: Json | null 119 | name: string | null 120 | } 121 | Insert: { 122 | active?: boolean | null 123 | description?: string | null 124 | id: string 125 | image?: string | null 126 | metadata?: Json | null 127 | name?: string | null 128 | } 129 | Update: { 130 | active?: boolean | null 131 | description?: string | null 132 | id?: string 133 | image?: string | null 134 | metadata?: Json | null 135 | name?: string | null 136 | } 137 | Relationships: [] 138 | } 139 | songs: { 140 | Row: { 141 | author: string | null 142 | created_at: string 143 | id: number 144 | image_path: string | null 145 | song_path: string | null 146 | title: string | null 147 | user_id: string | null 148 | } 149 | Insert: { 150 | author?: string | null 151 | created_at?: string 152 | id?: number 153 | image_path?: string | null 154 | song_path?: string | null 155 | title?: string | null 156 | user_id?: string | null 157 | } 158 | Update: { 159 | author?: string | null 160 | created_at?: string 161 | id?: number 162 | image_path?: string | null 163 | song_path?: string | null 164 | title?: string | null 165 | user_id?: string | null 166 | } 167 | Relationships: [ 168 | { 169 | foreignKeyName: "songs_user_id_fkey" 170 | columns: ["user_id"] 171 | isOneToOne: false 172 | referencedRelation: "users" 173 | referencedColumns: ["id"] 174 | }, 175 | ] 176 | } 177 | subscriptions: { 178 | Row: { 179 | cancel_at: string | null 180 | cancel_at_period_end: boolean | null 181 | canceled_at: string | null 182 | created: string 183 | current_period_end: string 184 | current_period_start: string 185 | ended_at: string | null 186 | id: string 187 | metadata: Json | null 188 | price_id: string | null 189 | quantity: number | null 190 | status: Database["public"]["Enums"]["subscription_status"] | null 191 | trial_end: string | null 192 | trial_start: string | null 193 | user_id: string 194 | } 195 | Insert: { 196 | cancel_at?: string | null 197 | cancel_at_period_end?: boolean | null 198 | canceled_at?: string | null 199 | created?: string 200 | current_period_end?: string 201 | current_period_start?: string 202 | ended_at?: string | null 203 | id: string 204 | metadata?: Json | null 205 | price_id?: string | null 206 | quantity?: number | null 207 | status?: Database["public"]["Enums"]["subscription_status"] | null 208 | trial_end?: string | null 209 | trial_start?: string | null 210 | user_id: string 211 | } 212 | Update: { 213 | cancel_at?: string | null 214 | cancel_at_period_end?: boolean | null 215 | canceled_at?: string | null 216 | created?: string 217 | current_period_end?: string 218 | current_period_start?: string 219 | ended_at?: string | null 220 | id?: string 221 | metadata?: Json | null 222 | price_id?: string | null 223 | quantity?: number | null 224 | status?: Database["public"]["Enums"]["subscription_status"] | null 225 | trial_end?: string | null 226 | trial_start?: string | null 227 | user_id?: string 228 | } 229 | Relationships: [ 230 | { 231 | foreignKeyName: "subscriptions_price_id_fkey" 232 | columns: ["price_id"] 233 | isOneToOne: false 234 | referencedRelation: "prices" 235 | referencedColumns: ["id"] 236 | }, 237 | { 238 | foreignKeyName: "subscriptions_user_id_fkey" 239 | columns: ["user_id"] 240 | isOneToOne: false 241 | referencedRelation: "users" 242 | referencedColumns: ["id"] 243 | }, 244 | ] 245 | } 246 | users: { 247 | Row: { 248 | avatar_url: string | null 249 | billing_address: Json | null 250 | full_name: string | null 251 | id: string 252 | payment_method: Json | null 253 | } 254 | Insert: { 255 | avatar_url?: string | null 256 | billing_address?: Json | null 257 | full_name?: string | null 258 | id: string 259 | payment_method?: Json | null 260 | } 261 | Update: { 262 | avatar_url?: string | null 263 | billing_address?: Json | null 264 | full_name?: string | null 265 | id?: string 266 | payment_method?: Json | null 267 | } 268 | Relationships: [ 269 | { 270 | foreignKeyName: "users_id_fkey" 271 | columns: ["id"] 272 | isOneToOne: true 273 | referencedRelation: "users" 274 | referencedColumns: ["id"] 275 | }, 276 | ] 277 | } 278 | } 279 | Views: { 280 | [_ in never]: never 281 | } 282 | Functions: { 283 | [_ in never]: never 284 | } 285 | Enums: { 286 | pricing_plan_interval: "day" | "week" | "month" | "year" 287 | pricing_type: "one_time" | "recurring" 288 | subscription_status: 289 | | "trialing" 290 | | "active" 291 | | "canceled" 292 | | "incomplete" 293 | | "incomplete_expired" 294 | | "past_due" 295 | | "unpaid" 296 | } 297 | CompositeTypes: { 298 | [_ in never]: never 299 | } 300 | } 301 | } 302 | 303 | type PublicSchema = Database[Extract] 304 | 305 | export type Tables< 306 | PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & PublicSchema["Views"]) | { schema: keyof Database }, 307 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 308 | ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 309 | Database[PublicTableNameOrOptions["schema"]]["Views"]) 310 | : never = never, 311 | > = PublicTableNameOrOptions extends { schema: keyof Database } 312 | ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 313 | Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { 314 | Row: infer R 315 | } 316 | ? R 317 | : never 318 | : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & PublicSchema["Views"]) 319 | ? (PublicSchema["Tables"] & PublicSchema["Views"])[PublicTableNameOrOptions] extends { 320 | Row: infer R 321 | } 322 | ? R 323 | : never 324 | : never 325 | 326 | export type TablesInsert< 327 | PublicTableNameOrOptions extends keyof PublicSchema["Tables"] | { schema: keyof Database }, 328 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 329 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 330 | : never = never, 331 | > = PublicTableNameOrOptions extends { schema: keyof Database } 332 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 333 | Insert: infer I 334 | } 335 | ? I 336 | : never 337 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] 338 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { 339 | Insert: infer I 340 | } 341 | ? I 342 | : never 343 | : never 344 | 345 | export type TablesUpdate< 346 | PublicTableNameOrOptions extends keyof PublicSchema["Tables"] | { schema: keyof Database }, 347 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 348 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 349 | : never = never, 350 | > = PublicTableNameOrOptions extends { schema: keyof Database } 351 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 352 | Update: infer U 353 | } 354 | ? U 355 | : never 356 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] 357 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { 358 | Update: infer U 359 | } 360 | ? U 361 | : never 362 | : never 363 | 364 | export type Enums< 365 | PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] | { schema: keyof Database }, 366 | EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } 367 | ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] 368 | : never = never, 369 | > = PublicEnumNameOrOptions extends { schema: keyof Database } 370 | ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] 371 | : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] 372 | ? PublicSchema["Enums"][PublicEnumNameOrOptions] 373 | : never 374 | --------------------------------------------------------------------------------