20201023のReactに関する記事は15件です。

5.TMDB リスト いいね 通知

はじめに

1.アプリケーション紹介
2.認証機能
3.TMDB api fetch
4.ジャンルフィルターとページ移動
5.リスト いいね 通知

今回はログイン後の機能でリストといいねと公開前の映画を通知希望のリストに追加するとヘッダーで通知するようにします。
コードに関しては、githubにて後悔しておりますので、こちらからどうぞ!

リスト

リスト.gif

こんな感じでユーザーリストを作理、リストに追加できるようにします。

まずはじめにfirestoreないから、、、

firebase内のデータベース設計

ユーザー1人に、folderコレクションを作ります。
そこにはユーザーとは別のコレクションのfolderに映画をセットしていくようになります。

下記がユーザーの中のfolderコレクションなります。こちらのidからfolderコレクションと紐付けしていきます。
スクリーンショット 2020-10-23 19.20.39.png

そしてこちらがユーザーとは別のfolderコレクションの中身で実際にはこちらに映画をセットしていきます。
スクリーンショット 2020-10-23 19.22.09.png

リスト作成

export const makeFolder = (uid: string, folderName: string) => {
    return async (dispatch:any) => {
        const ref = folderRef.doc()
        const folderId = ref.id
        folderRef.doc(folderId).set({
            created_at: FirebaseTimestamp.now(),
            name: folderName,
            uid: uid,
        })
        usersRef.doc(uid).collection('folder').doc(folderId).set({
            name: folderName,
            id: folderId,
            created_at: FirebaseTimestamp.now(),
        })
    }
}

まずfolderコレクションに作成日時とfolderの名前と作成者をセット、
そしてユーザーのfolderコレクションにも紐付けを行うためにfolderIdをセットします。

リスト削除

export const deleteFolder = (uid: string, folderId: string) => {
    return async (dispatch:any) => {
        folderRef.doc(folderId).delete()
        usersRef.doc(uid).collection('folder').doc(folderId).delete()
    }
}

削除ボタンを押すと削除できるようにこちらも書いておきます。

リストへの追加

export const addFolderMovie = (folderId: string, movie:movie) => {
    return async (dispatch: any) => {
        folderRef.doc(folderId).collection('movie').get()
        .then(snapshot => {
            const match = []
            snapshot.docs.forEach(doc => {
                const data  = doc.data()
                if(data.id === movie.id){
                    match.push(data)
                }
            })
            if(match.length > 0){
                return false
            }else{
                const ref = usersRef.doc(folderId).collection('movie').doc()
                const movieId = ref.id
                const data = {
                    movieId: movieId,
                    id: movie.id,
                    title: movie.title,
                    poster_path: movie.poster_path,
                    backdrop_path: movie.backdrop_path,
                    release_date: movie.release_date,
                    genres: movie.genres,
                    overview: movie.overview,
                    vote_average: movie.vote_average,
                    timestamp: FirebaseTimestamp.now()   
                }
                folderRef.doc(folderId).collection('movie').doc(movieId).set(data)
            }
        })
    }
}

これがfolderへの追加のコードですが、同じ映画がセットされることのないように一度、追加するfolder内の映画を取得して入ってきた映画とかぶっていないか確認しています。
そして被っていなければ映画をセットするようにしています。

リストから映画を削除

export const deleteFolderMovie = (folderId:string, movie: movie) => {
    return async (dispatch:any) => {
        folderRef.doc(folderId).collection('movie').get()
        .then(snapshot => {
            const match:any = []
            snapshot.docs.forEach(doc => {
                const data = doc.data()
                if(data.id === movie.id){
                    match.push(data)
                }
            })
            if(match.length === 0){
                return false
            }else{
                folderRef.doc(folderId).collection('movie').doc(match[0].movieId).delete()        
            }
        })
    }
}

こちらでも同様に削除するものがなければfalseを返しています。
そしてあればfirestore内から削除するようにしています。

これがリストの作成、削除、映画の追加、削除の処理になります。

いいね

こちらはユーザーログイン時にデフォルトで入っているお気に入り機能になります。
そのため、先ほどのリストへ映画追加と削除のコードとほとんど同じですので詳しい説明は省きます。

export const deleteFavoriteMovie = (id: number) => {
    return async (dispatch:any, getState:any) => {
        const uid = getState().user.uid
        usersRef.doc(uid).collection('favorite').get()
        .then(snapshot => {
            const match:any = []
            snapshot.docs.filter(doc => {
                const data = doc.data()
                if(data.id === id){
                match.push(data)
                }
            })
            if(match.length === 0){
                return false
            }else{
                console.log(match)
                usersRef.doc(uid).collection('favorite').doc(match[0].movieId).delete()
            }
        })  
    }
}

export const addFavoriteMovie = (movie: movie) => {
    return async (dispatch: any, getState:any) => {
        const uid = getState().user.uid
        usersRef.doc(uid).collection('favorite').get()
        .then(snapshot => {
            const match = snapshot.docs.filter(doc => {
                const data = doc.data()
                return data.id === movie.id
            })
            if(match.length > 0){
                return false
            }else{
                const ref = usersRef.doc(uid).collection('favorite').doc()
                const movieId = ref.id
                const data = {
                    movieId: movieId,
                    id: movie.id,
                    title: movie.title,
                    poster_path: movie.poster_path,
                    backdrop_path: movie.backdrop_path,
                    release_date: movie.release_date,
                    genres: movie.genres,
                    overview: movie.overview,
                    vote_average: movie.vote_average,
                    timestamp: FirebaseTimestamp.now()
                }
                usersRef.doc(uid).collection('favorite').doc(movieId).set(data)
            }
        })
    }
}

公開作品通知

ユーザーコレクションのnotificationコレクションを用意、こちらも同様に初ログインからデフォルトで入っているリストになります。
そして未公開の場合にだけこちらのリストに追加できるようにします。
notification.gif
これに関しては、映画のfetchした情報の中に公開日も入っているので、その日にちがまだきていなければnotificationコレクションへの追加ボタンを用意します。

今回のコードはいいねの追加と全く同じなので追加と削除のコードは省きます。

firestoreからnotificatioコレクションをfetchしてきて公開日から一週間を切った場合、ヘッダーから通知されるようになります。

    const [message, setMessage] = useState("")

    useEffect(() => {
        const release = movie.release_date.split('-')
        const year = release[0]
        const month = release[1]
        const date = release[2]
        const releaseDate = `${year}/${month}/${date} 00:00:00`
        let today:any = new Date()
        const data:any = Date.parse(releaseDate)
        const item = data - today
        if(item > 0){
            if(item < 86400000){
                setMessage("明日公開!!")
            }else if(item < 172800000){
                setMessage('残り2日!')
            }else if(item < 259200000){
                setMessage('残り3日!')
            }else if(item < 345600000){
                setMessage('残り4日!')
            }else if(item < 432000000){
                setMessage('残り5日!')
            }else if(item < 518400000){
                setMessage('残り6日!')
            }else if(item < 604800000){
                setMessage('残り7日!')
            }
        }else{
            if(item > -604800000){
                setMessage('公開中')
            }else{
                db.collection('user').doc(displayUid).collection('notification').doc(movie.movieId).delete()
            }
        }
    },[])

ここでは、fetchした映画の配列を回し、映画オブジェクトを渡されている状態です。
そして渡ってきた映画が公開日から何日過ぎているか残り7日から通知するようになっています。
公開まで7日以上の場合は、通知されないようになっています。
公開してから一週間がすぎると、自動でnotificationコレクションから削除するようします。

終わりに

今回でこのアプリについての記事を終わろうと思います。
主な機能の実装方法のみ記事にしております。ので他の詳しいコードなどが知りたい方はこちらのgithubからどうぞ!

今回、UI構築は、material-uiをふんだんに使いました。
そのおかげでデザインの知識がない私でも、充実したものになったので、本当に便利だと感じました。
そしてそのデザインもTMDB公式のアプリを大いに似せていただきました。これから勉強を重ねていきたいと思っております。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

6.TMDB リスト いいね 通知

はじめに

1.アプリケーション紹介
2.認証機能
3.TMDB api fetch
4.auto-suggestion検索フォーム
5.ジャンルフィルターとページ移動
6.リスト いいね 通知

今回はログイン後の機能でリストといいねと公開前の映画を通知希望のリストに追加するとヘッダーで通知するようにします。
コードに関しては、githubにて後悔しておりますので、こちらからどうぞ!

リスト

リスト.gif

こんな感じでユーザーリストを作理、リストに追加できるようにします。

まずはじめにfirestoreないから、、、

firebase内のデータベース設計

ユーザー1人に、folderコレクションを作ります。
そこにはユーザーとは別のコレクションのfolderに映画をセットしていくようになります。

下記がユーザーの中のfolderコレクションなります。こちらのidからfolderコレクションと紐付けしていきます。
スクリーンショット 2020-10-23 19.20.39.png

そしてこちらがユーザーとは別のfolderコレクションの中身で実際にはこちらに映画をセットしていきます。
スクリーンショット 2020-10-23 19.22.09.png

リスト作成

export const makeFolder = (uid: string, folderName: string) => {
    return async (dispatch:any) => {
        const ref = folderRef.doc()
        const folderId = ref.id
        folderRef.doc(folderId).set({
            created_at: FirebaseTimestamp.now(),
            name: folderName,
            uid: uid,
        })
        usersRef.doc(uid).collection('folder').doc(folderId).set({
            name: folderName,
            id: folderId,
            created_at: FirebaseTimestamp.now(),
        })
    }
}

まずfolderコレクションに作成日時とfolderの名前と作成者をセット、
そしてユーザーのfolderコレクションにも紐付けを行うためにfolderIdをセットします。

リスト削除

export const deleteFolder = (uid: string, folderId: string) => {
    return async (dispatch:any) => {
        folderRef.doc(folderId).delete()
        usersRef.doc(uid).collection('folder').doc(folderId).delete()
    }
}

削除ボタンを押すと削除できるようにこちらも書いておきます。

リストへの追加

export const addFolderMovie = (folderId: string, movie:movie) => {
    return async (dispatch: any) => {
        folderRef.doc(folderId).collection('movie').get()
        .then(snapshot => {
            const match = []
            snapshot.docs.forEach(doc => {
                const data  = doc.data()
                if(data.id === movie.id){
                    match.push(data)
                }
            })
            if(match.length > 0){
                return false
            }else{
                const ref = usersRef.doc(folderId).collection('movie').doc()
                const movieId = ref.id
                const data = {
                    movieId: movieId,
                    id: movie.id,
                    title: movie.title,
                    poster_path: movie.poster_path,
                    backdrop_path: movie.backdrop_path,
                    release_date: movie.release_date,
                    genres: movie.genres,
                    overview: movie.overview,
                    vote_average: movie.vote_average,
                    timestamp: FirebaseTimestamp.now()   
                }
                folderRef.doc(folderId).collection('movie').doc(movieId).set(data)
            }
        })
    }
}

これがfolderへの追加のコードですが、同じ映画がセットされることのないように一度、追加するfolder内の映画を取得して入ってきた映画とかぶっていないか確認しています。
そして被っていなければ映画をセットするようにしています。

リストから映画を削除

export const deleteFolderMovie = (folderId:string, movie: movie) => {
    return async (dispatch:any) => {
        folderRef.doc(folderId).collection('movie').get()
        .then(snapshot => {
            const match:any = []
            snapshot.docs.forEach(doc => {
                const data = doc.data()
                if(data.id === movie.id){
                    match.push(data)
                }
            })
            if(match.length === 0){
                return false
            }else{
                folderRef.doc(folderId).collection('movie').doc(match[0].movieId).delete()        
            }
        })
    }
}

こちらでも同様に削除するものがなければfalseを返しています。
そしてあればfirestore内から削除するようにしています。

これがリストの作成、削除、映画の追加、削除の処理になります。

いいね

こちらはユーザーログイン時にデフォルトで入っているお気に入り機能になります。
そのため、先ほどのリストへ映画追加と削除のコードとほとんど同じですので詳しい説明は省きます。

export const deleteFavoriteMovie = (id: number) => {
    return async (dispatch:any, getState:any) => {
        const uid = getState().user.uid
        usersRef.doc(uid).collection('favorite').get()
        .then(snapshot => {
            const match:any = []
            snapshot.docs.filter(doc => {
                const data = doc.data()
                if(data.id === id){
                match.push(data)
                }
            })
            if(match.length === 0){
                return false
            }else{
                console.log(match)
                usersRef.doc(uid).collection('favorite').doc(match[0].movieId).delete()
            }
        })  
    }
}

export const addFavoriteMovie = (movie: movie) => {
    return async (dispatch: any, getState:any) => {
        const uid = getState().user.uid
        usersRef.doc(uid).collection('favorite').get()
        .then(snapshot => {
            const match = snapshot.docs.filter(doc => {
                const data = doc.data()
                return data.id === movie.id
            })
            if(match.length > 0){
                return false
            }else{
                const ref = usersRef.doc(uid).collection('favorite').doc()
                const movieId = ref.id
                const data = {
                    movieId: movieId,
                    id: movie.id,
                    title: movie.title,
                    poster_path: movie.poster_path,
                    backdrop_path: movie.backdrop_path,
                    release_date: movie.release_date,
                    genres: movie.genres,
                    overview: movie.overview,
                    vote_average: movie.vote_average,
                    timestamp: FirebaseTimestamp.now()
                }
                usersRef.doc(uid).collection('favorite').doc(movieId).set(data)
            }
        })
    }
}

公開作品通知

ユーザーコレクションのnotificationコレクションを用意、こちらも同様に初ログインからデフォルトで入っているリストになります。
そして未公開の場合にだけこちらのリストに追加できるようにします。
notification.gif
これに関しては、映画のfetchした情報の中に公開日も入っているので、その日にちがまだきていなければnotificationコレクションへの追加ボタンを用意します。

今回のコードはいいねの追加と全く同じなので追加と削除のコードは省きます。

firestoreからnotificatioコレクションをfetchしてきて公開日から一週間を切った場合、ヘッダーから通知されるようになります。

    const [message, setMessage] = useState("")

    useEffect(() => {
        const release = movie.release_date.split('-')
        const year = release[0]
        const month = release[1]
        const date = release[2]
        const releaseDate = `${year}/${month}/${date} 00:00:00`
        let today:any = new Date()
        const data:any = Date.parse(releaseDate)
        const item = data - today
        if(item > 0){
            if(item < 86400000){
                setMessage("明日公開!!")
            }else if(item < 172800000){
                setMessage('残り2日!')
            }else if(item < 259200000){
                setMessage('残り3日!')
            }else if(item < 345600000){
                setMessage('残り4日!')
            }else if(item < 432000000){
                setMessage('残り5日!')
            }else if(item < 518400000){
                setMessage('残り6日!')
            }else if(item < 604800000){
                setMessage('残り7日!')
            }
        }else{
            if(item > -604800000){
                setMessage('公開中')
            }else{
                db.collection('user').doc(displayUid).collection('notification').doc(movie.movieId).delete()
            }
        }
    },[])

ここでは、fetchした映画の配列を回し、映画オブジェクトを渡されている状態です。
そして渡ってきた映画が公開日から何日過ぎているか残り7日から通知するようになっています。
公開まで7日以上の場合は、通知されないようになっています。
公開してから一週間がすぎると、自動でnotificationコレクションから削除するようします。

終わりに

今回でこのアプリについての記事を終わろうと思います。
主な機能の実装方法のみ記事にしております。ので他の詳しいコードなどが知りたい方はこちらのgithubからどうぞ!

今回、UI構築は、material-uiをふんだんに使いました。
そのおかげでデザインの知識がない私でも、充実したものになったので、本当に便利だと感じました。
そしてそのデザインもTMDB公式のアプリを大いに似せていただきました。これから勉強を重ねていきたいと思っております。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

4.TMDB ジャンルフィルターとページ移動

はじめに

1.アプリケーション紹介
2.認証機能
3.TMDB api fetch
4.ジャンルフィルターとページ移動
5.リスト いいね 通知

アプリの概要など他にもこのアプリケーションについての記事を載せておりますのでそちらの方がみたい方はそちらをどうぞ!
コードもgithubにてのせておりますので、こちらからどうぞ!
今回は、ジャンルを選択したときにフィルターにかけてapiをたたいて映画の取得を行います。

そしてページ遷移ができるコードも載せていきたい思っております。

ジャンルフィルター

タイトルなし.gif

こんな感じのジャンルフィルターを作っていきます。

//selectGenreは、ジャンルボタンを選択した時にそのジャンルをこのstateにせっとする。この中にジャンルが入ってくる!
    const [selectGenre, setSelectGenre] = useState<genre[]>([])

//ジャンルボタンの選択後の挙動
    const toggleGenre = (genre: genre) => {
        const filteredGenres = selectGenre.filter((g:genre) => g.id !== genre.id)
        if(filteredGenres.length === selectGenre.length){
            setSelectGenre([
                ...filteredGenres,
                genre,
            ])
        }else{
            setSelectGenre([
                ...filteredGenres,
            ])
        }
    }
//selectGenreがセットされるたびにfetchするようにする
    useEffect(() => {
        const genreIDs = selectGenre.map((g: genre):number => {
            return g.id
        })
        if(path === '/'){
            dispatch(fetchMovieList(API_GET_MOVIE_POPULAR, genreIDs))
        }else if(path === '/upcoming'){
            dispatch(fetchMovieList(API_GET_MOVIE_UPCOMING, genreIDs))
        }else if(path === '/now_playing'){
            dispatch(fetchMovieList(API_GET_MOVIE_NOW_PLAYING, genreIDs))
        }else if(path === '/top_rated'){
            dispatch(fetchMovieList(API_GET_MOVIE_TOP_RATED, genreIDs))
        }
    },[selectGenre, path])

export const API_GET_MOVIE_POPULAR = 'movie/popular';
export const API_GET_MOVIE_UPCOMING = 'movie/upcoming'
export const API_GET_MOVIE_NOW_PLAYING = 'movie/now_playing'
export const API_GET_MOVIE_TOP_RATED = 'movie/top_rated'

1.selectGenreをフィルターにかけて、一個目にセットしたジャンルとかぶっていた場合は、それ以外をstateにセットする、またかぶっていなければそのままstateをセット

2.そしてselectGenreのstateがセットされるごとにデータをfetchする(ジャンルボタンをクリック)
今回は、公開中(/now_playing)、人気(/)、高評価(top_rated)、新作公開(upcoming)も同じコンポーネントで表示するのでパスによってfetchするときのURIが変わるようにしています。

ページ遷移

タイトルなし.gif
こちらはページ遷移です。

//次にページをセット
   const prevPage = (page - 1) <= 0 ? 1 : (page - 1);
//ページを一個戻る挙動
    const nextPage = (page + 1) > total_pages ? total_pages : (parseInt(Page, 10) + parseInt('1', 10));
//そして次へボタンへのchangePageは、nextPageを戻るボタンへのchangePageは、prevPageを渡す
    const changePage = (page: number) => {
        if(page === 0){
            alert('該当の作品はありませんでした。')
            return false
        }else{
            if(typeof(Storage) !== 'undefined'){
                localStorage.setItem('currentPage', JSON.stringify(page))
            }
            const GenresID = selectGenre.map((g:genre) => g.id)
            if(path === '/'){
                dispatch(fetchMovieList(API_GET_MOVIE_POPULAR, GenresID, page))
            }else if(path === '/upcoming'){
                dispatch(fetchMovieList(API_GET_MOVIE_UPCOMING, GenresID, page))
            }else if(path === '/now_playing'){
                dispatch(fetchMovieList(API_GET_MOVIE_NOW_PLAYING, GenresID, page))
            }else if(path === '/top_rated'){
                dispatch(fetchMovieList(API_GET_MOVIE_TOP_RATED, GenresID, page))
            }        
         }
    }

return(
          <button
            type="button"
            title="Previous 20 movies"
            onClick={() => changePage(prevPage)}
          >
            Prev
          </button>
          <div>
            {page}
            <span> / </span>
            {total_pages}
          </div>
          <button
            type="button"
            title="Next 20 movies"
            onClick={() => changePage(nextPage)}
          >
            Next
          </button>
)

1.prevPageは、page数が1以外の場合は、pageステートを-1する

2.nextPageは、fetchしてきた情報の中にトータルのページ数も入っているので、そのトータルページ数とpageステートが同じではない場合は、pageを+1するようになっている。

3.このprevPageは、戻るボタンをクリックした時にchangePageの引数として渡す。次へボタンを押したらnextPageを下記のように引数として渡す。

4.前、次のページのボタンがクリックされるとpageステートがそれに応じて変化してそれを各pathの映画のfetchメソッドに渡すことでpageを戻ったり、次へ進んだりできるようになる。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

5.TMDB ジャンルフィルターとページ移動

はじめに

1.アプリケーション紹介
2.認証機能
3.TMDB api fetch
4.auto-suggestion検索フォーム
5.ジャンルフィルターとページ移動
6.リスト いいね 通知

アプリの概要など他にもこのアプリケーションについての記事を載せておりますのでそちらの方がみたい方はそちらをどうぞ!
コードもgithubにてのせておりますので、こちらからどうぞ!
今回は、ジャンルを選択したときにフィルターにかけてapiをたたいて映画の取得を行います。

そしてページ遷移ができるコードも載せていきたい思っております。

ジャンルフィルター

タイトルなし.gif

こんな感じのジャンルフィルターを作っていきます。

//selectGenreは、ジャンルボタンを選択した時にそのジャンルをこのstateにせっとする。この中にジャンルが入ってくる!
    const [selectGenre, setSelectGenre] = useState<genre[]>([])

//ジャンルボタンの選択後の挙動
    const toggleGenre = (genre: genre) => {
        const filteredGenres = selectGenre.filter((g:genre) => g.id !== genre.id)
        if(filteredGenres.length === selectGenre.length){
            setSelectGenre([
                ...filteredGenres,
                genre,
            ])
        }else{
            setSelectGenre([
                ...filteredGenres,
            ])
        }
    }
//selectGenreがセットされるたびにfetchするようにする
    useEffect(() => {
        const genreIDs = selectGenre.map((g: genre):number => {
            return g.id
        })
        if(path === '/'){
            dispatch(fetchMovieList(API_GET_MOVIE_POPULAR, genreIDs))
        }else if(path === '/upcoming'){
            dispatch(fetchMovieList(API_GET_MOVIE_UPCOMING, genreIDs))
        }else if(path === '/now_playing'){
            dispatch(fetchMovieList(API_GET_MOVIE_NOW_PLAYING, genreIDs))
        }else if(path === '/top_rated'){
            dispatch(fetchMovieList(API_GET_MOVIE_TOP_RATED, genreIDs))
        }
    },[selectGenre, path])

export const API_GET_MOVIE_POPULAR = 'movie/popular';
export const API_GET_MOVIE_UPCOMING = 'movie/upcoming'
export const API_GET_MOVIE_NOW_PLAYING = 'movie/now_playing'
export const API_GET_MOVIE_TOP_RATED = 'movie/top_rated'

1.selectGenreをフィルターにかけて、一個目にセットしたジャンルとかぶっていた場合は、それ以外をstateにセットする、またかぶっていなければそのままstateをセット

2.そしてselectGenreのstateがセットされるごとにデータをfetchする(ジャンルボタンをクリック)
今回は、公開中(/now_playing)、人気(/)、高評価(top_rated)、新作公開(upcoming)も同じコンポーネントで表示するのでパスによってfetchするときのURIが変わるようにしています。

ページ遷移

タイトルなし.gif
こちらはページ遷移です。

//次にページをセット
   const prevPage = (page - 1) <= 0 ? 1 : (page - 1);
//ページを一個戻る挙動
    const nextPage = (page + 1) > total_pages ? total_pages : (parseInt(Page, 10) + parseInt('1', 10));
//そして次へボタンへのchangePageは、nextPageを戻るボタンへのchangePageは、prevPageを渡す
    const changePage = (page: number) => {
        if(page === 0){
            alert('該当の作品はありませんでした。')
            return false
        }else{
            if(typeof(Storage) !== 'undefined'){
                localStorage.setItem('currentPage', JSON.stringify(page))
            }
            const GenresID = selectGenre.map((g:genre) => g.id)
            if(path === '/'){
                dispatch(fetchMovieList(API_GET_MOVIE_POPULAR, GenresID, page))
            }else if(path === '/upcoming'){
                dispatch(fetchMovieList(API_GET_MOVIE_UPCOMING, GenresID, page))
            }else if(path === '/now_playing'){
                dispatch(fetchMovieList(API_GET_MOVIE_NOW_PLAYING, GenresID, page))
            }else if(path === '/top_rated'){
                dispatch(fetchMovieList(API_GET_MOVIE_TOP_RATED, GenresID, page))
            }        
         }
    }

return(
          <button
            type="button"
            title="Previous 20 movies"
            onClick={() => changePage(prevPage)}
          >
            Prev
          </button>
          <div>
            {page}
            <span> / </span>
            {total_pages}
          </div>
          <button
            type="button"
            title="Next 20 movies"
            onClick={() => changePage(nextPage)}
          >
            Next
          </button>
)

1.prevPageは、page数が1以外の場合は、pageステートを-1する

2.nextPageは、fetchしてきた情報の中にトータルのページ数も入っているので、そのトータルページ数とpageステートが同じではない場合は、pageを+1するようになっている。

3.このprevPageは、戻るボタンをクリックした時にchangePageの引数として渡す。次へボタンを押したらnextPageを下記のように引数として渡す。

4.前、次のページのボタンがクリックされるとpageステートがそれに応じて変化してそれを各pathの映画のfetchメソッドに渡すことでpageを戻ったり、次へ進んだりできるようになる。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

3.TMDB データfetch

はじめに

1.アプリケーション紹介
2.認証機能
3.TMDB api fetch
4.auto-suggestion検索フォーム
5.ジャンルフィルターとページ移動
6.リスト いいね 通知

今回は実際にapi叩く処理を載せていきます。
tmdbのapiは充実しているのでより詳しく映画の詳細の表示やフィルタリングができました。
どんなアプケーションかは、1回目の記事にて載せておりますのでみていただけると幸いです。

コードがあればいいという方は、こちらからどうぞ!

概要

今回は、新作公開(upcoming)、人気(popular)、公開中(now_playing)、高評価(top_rated)を映画のリストを表示できるようにします。
それごとにジャンルのフィルターをかけられるようにしています。

ジャンル.gif

映画リストfetch

export const fetchMovieList = (API_GET_MOVIE_BY = API_GET_MOVIE_POPULAR, genreIDs: number[], page = 1) => {
    const genreParams = genreIDs ? `${API_PARAMS_GENRE}${genreIDs.join('%2C')}` : '';
        return async (dispatch: any) => {
        dispatch(fetchMovie())
        return fetch(`${URL}${API_GET_MOVIE_BY}${API_KEY}${API_PARAMS_PAGE}${page}${genreParams}`)
        .then(response => response.json())
        .then(json => dispatch(fetchMovieSuccess(json.results, json.page, json.total_pages)))
        .catch(error => dispatch(fetchMovieFailure(error)))
    }
}
export const URL = 'https://api.themoviedb.org/3/';
export const API_GET_MOVIE_POPULAR = 'movie/popular';
export const API_GET_MOVIE_UPCOMING = 'movie/upcoming'
export const API_GET_MOVIE_NOW_PLAYING = 'movie/now_playing'
export const API_GET_MOVIE_TOP_RATED = 'movie/top_rated'

こちらが、映画のジャンルのリストを叩く処理になります。
最初に引数であるAPI_GET_MOVIE_BYは、デフォルトだと人気popularのapiを叩くので、デフォルトはpopularのapiを設定しておきます。

genresIDsは、ジャンル選択した時にgenreIDsを引数でわたします。page遷移も行いますので最初はデフォルトの1を設定しておきます。

https://api.themoviedb.org/3/movie/now_playing?api_key=API_KEY&page=1&with_genres=99%2C35

urlは上記の形で叩くのでgenresのパラメータでは、genreIdごとに%2Cを入れなければいけないので、genreParamsにて代入します。
fetchしている時、fetchが成功した時、失敗した時にそれぞれreducerに渡してstoreで更新します。

映画検索fetch

export const searchMovieList = (keyword: string) => {
    let url = URL_SEARCH + keyword + API_KEY_ALT;
    return async (dispatch: any) => {
        dispatch(searchMovie(keyword))
        return fetch(url)
        .then(response => response.json())
        .then(json => json.results)
        .then(data => dispatch(searchMovieSuccess(data, keyword)))
        .catch(error => dispatch(searchMovieFailure(error)))
    }
}

export const URL_SEARCH = 'https://api.themoviedb.org/3/search/movie?query=';

映画詳細関連fetch

export const fetchMovieDetail = (id: string) => {
    const url_movie = URL_DETAIL + id + API_KEY;
    return async (dispatch: any) => {
        dispatch(fetchMovieDetailAction())
        return fetch(url_movie)
            .then(response => response.json())
            .then(data => dispatch(fetchMovieDetailSuccess(data)))
            .catch(error => dispatch(fetchMovieDetailFailure(error)))
    }
} 

export const URL_DETAIL = 'https://api.themoviedb.org/3/movie/';

まずここでは、映画のidを使って映画の詳細取得します。
そしてその取得後の情報をもとに、youtubeの関連動画、俳優リスト、関連映画の取得を行います。

export const fetchTrailerList = (id: string) => {
    const url_trailers = URL_DETAIL + id + URL_VIDEO + API_KEY;
    return async (dispatch: any) => {
        dispatch(fetchTrailers())
        return fetch(url_trailers)
        .then(response => response.json())
        .then(json => json.results)
        .then(data => {
            let youtubeTrailers = data.filter((trailer:any) => {
                return trailer.site === 'YouTube';
            })
            dispatch(fetchTrailersSuccess(youtubeTrailers));
        })
        .catch(error => dispatch(fetchTrailersFailure(error)))
    }
}

export const fetchCastList = (id: string) => {
    const url_casts = URL_DETAIL + id + URL_CAST + API_KEY;
    return async (dispatch: any) => {
        dispatch(fetchCasts())
        return fetch(url_casts)
            .then(response => response.json())
            .then(json => json.cast)
            .then(data => dispatch(fetchCastsSuccess(data)))
            .catch(error => dispatch(fetchCastsFailure(error)))      
    }
}

export const fetchSimilarMovies = (movieID: string) => {
    let url = URL + API_GET_MOVIE_SIMILAR(movieID) + API_KEY
    return async (dispatch: any) => {
        dispatch(fetchMovie())
        return fetch(url)
            .then(responnse => responnse.json())
            .then(json => json.results)
            .then(data => dispatch(fetchMovieSuccess(data, 0, 0)))
            .catch(error => dispatch(fetchMovieFailure(error)))
    }
}
export const URL_VIDEO = '/videos';
export const URL_CAST = '/casts';
export const URL_DETAIL = 'https://api.themoviedb.org/3/movie/';
export const URL = 'https://api.themoviedb.org/3/';
export const API_GET_MOVIE_SIMILAR = (movieID: any) => `movie/${movieID}/similar`;

tmdbのapiでは、関連の予告動画などやキャストや似ている映画などのapiも続けて叩けるようになっているので、それをつかって
より詳しく映画の詳細を表示できるようになっています。
映画詳細.gif
映画の詳細はこんな感じです!

俳優詳細fetch

export const fetchActorDetail = (id: string) => {
    const url_actor = URL_PERSON + id + API_KEY;
    return async (dispatch:any) => {
        dispatch(fetchActor())
        return fetch(url_actor)
            .then(response => response.json())
            .then(data => dispatch(fetchActorSuccess(data)))
            .catch(error => dispatch(fetchActorFailure(error)))
    }
}

export const URL_PERSON = 'https://api.themoviedb.org/3/person/';

actorIdを使って俳優の詳細を取得します。
そしてその俳優の出演作品の情報を取得します

export const fetchActorMovieList = (id:string) => {
    let url: string;
    if(id)url = URL_LIST + API_KEY + '&with_cast=' + id;
    else url = URL_LIST + API_KEY;
    return async (dispatch:any) => {
        dispatch(fetchMovie());
        return fetch(url)
            .then(response => response.json())
            .then(json => json.results)
            .then(data => dispatch(fetchMovieSuccess(data, 0, 0)))
            .catch(error => dispatch(fetchMovieFailure(error)))
    }
}

export const URL_LIST = 'https://api.themoviedb.org/3/discover/movie';

俳優詳細のページに出演作品も掲載します。
スクリーンショット 2020-10-22 22.43.54.png

こんな感じで表示します。デザインはTMDBに似せております。

ページのデザインやなどはgithubのコードにて確認できます。
ここでは省かせていただきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

2.TMDB 認証機能とページ構成

はじめに

1.アプリケーション紹介
2.認証機能
3.TMDB api fetch
4auto-suggestion検索フォーム
5.ジャンルフィルターとページ移動
6.リスト いいね 通知

今回認証機能についての記事になります。
firebaseを使って実装しました。
以下が認証用ページになります。

スクリーンショット 2020-10-22 20.56.54.png
スクリーンショット 2020-10-22 20.57.25.png
スクリーンショット 2020-10-22 20.57.50.png

サインアップ

export const signUp = (username:string, email:string,genres:Array<{id: number, name: string}>, password:string, confirmPassword:string) => {
    return async (dispatch:any) => {
        if(username === "" || email === "" || password === ""){
            alert('必須項目が未入力です')
            return false
        }
        if(password !== confirmPassword){
            alert('パスワードが一致しません')
            return false
        }
        return auth.createUserWithEmailAndPassword(email, password)
        .then(result => {
            const user = result.user
            if(user){
                const uid = user.uid
                const timestamp = FirebaseTimestamp.now()
                const userData = {
                    uid: uid,
                    genres: genres,
                    email: email,
                    username: username,
                    created_at: timestamp,
                }
                usersRef.doc(uid).set(userData)
                .then(() => {
                    dispatch(push('/signin'))
                })
                .catch(error => alert('通信環境を整えて再度試して下さい。'))
            }
        })
    }
}

認証機能と同時にfirestoreのuserコレクションにユーザーデータをセットするようにしております。
バリデーションは、他にもあると思いますが、まだ実装できておりません。
メールの形式などでのバリデーションもするべきなのでのちに実装していこうと考えております。

サインイン

export const signIn = (email: string, password: string) => {
    return async (dispatch: any) => {
        if(email === "" || password === ""){
            alert('必須項目が未入力です')
            return false
        }
        auth.signInWithEmailAndPassword(email, password)
        .then(result => {
            const user = result.user
            if(user){
                const uid = user.uid
                db.collection('user').doc(uid).get()
                .then(snapshot => {
                    const data:any = snapshot.data()
                    dispatch(signInAction({
                        isSignedIn: true,
                        username: data.username,
                        uid: data.uid,
                        genres: data.genres,
                    }))
                    dispatch(push('/'))
                })
            }
        })
    }
}

リセットパスワード

export const resetPassword = (email:string) => {
    return async (dispatch: any) =>  {
        if(email === ""){
            alert('必須項目が未入力です')
            return false
        }else{
            auth.sendPasswordResetEmail(email)
            .then(() => {
                alert('入力されたアドレスにパスワードリセット用のメールを送信しました。')
                dispatch(push('/signin'))
            })
            .catch(() => {
                alert('メールの送信に失敗しました。通信環境を整えて再度試してください。')
            })
        }
    }
}

サインアウト

export const signOut = () => {
    return async (dispatch: any) => { 
        auth.signOut()
        .then(() => {
            dispatch(signOutAction())
        })
        .catch((error) => {
            console.log(error)
        })
    }
}

Authコンポーネント

    useEffect(() => {
        if (!isSignedIn) {
            dispatch(listenAuthState())
        }
    },[])

    if(!isSignedIn){
        if(path === '/mylist'){
            dispatch(push('/'))
            return children
        }
        return children
    }else{
        if(path === '/signin'){
            dispatch(push('/'))
        }
        return children
    }

pathは、パスネームになります。/mylistは、ログインしないと入れないページなのでそのページに入ると違うページへ、Redirectされるようになっております。

そして自動ログインのリッスンのコードはこちらです

export const listenAuthState = () => {
    return async (dispatch:any) => {
        return auth.onAuthStateChanged(user => {
            if(user){
                const uid = user.uid
                usersRef.doc(uid).get()
                .then(snapshot => {
                    const data = snapshot.data()
                    if(!data){
                        throw new Error('ユーザーデータが存在しません')
                    }
                    dispatch(signInAction({
                        isSignedIn: true,
                        username: data.username,
                        uid: uid,
                        genres: data.genres,
                    }))
                })     
            }
        })
    }
}

今回、reduxを使っておりますので、actionにデータを渡すようにしております。

ルーティング

  <Provider store={store}>
    <ConnectedRouter history={history}>
      <Switch>
        <AuthWrapper>
        <Header />
        <Route exact path="/signup" component={SignUp}/>
        <Route exact path="/signin" component={SignIn}/>
        <Route exact path="/reset" component={Reset}/>
        <Route exact path="/" component={MovieContainer}/>
        <Route exact path="/upcoming" component={MovieContainer}/>
        <Route exact path="/now_playing" component={MovieContainer}/>
        <Route exact path="/top_rated" component={MovieContainer}/>
        <Route path="/search(/:keyword)?" component={MovieContainer}/>
        <Route path="/movie(/:id)?" component={MovieDetail}/>
        <Route path="/actor(/:id)?" component={Actor}/>
        <Route exact path="/mylist" component={MyList}/>
        </AuthWrapper>
      </Switch>
    </ConnectedRouter>
  </Provider>,

今回のページ構成です。先ほどのAuthコンポーネントがAuthWrapperになります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

1.TMDB apiを使ったアプリケーション紹介

はじめに

1.アプリケーション紹介
2.認証機能
3.TMDB api fetch
4.auto-suggestion検索フォーム
5.ジャンルフィルターとページ移動
6.リスト いいね 通知

今回TMDBのapiを使って映画検索のアプケーションを作ってみました。
cssの勉強不足なのでデザインに関しては、TMDBのアプリケーションをまねて作らせていただきました。
こちらになります。
プログラミング勉強したての初心者の制作物になりますので、暖かくみていただけると幸いです。
githubにて公開もしておりますので、コードのみ知りたい方は以下からどうぞ!!
https://github.com/yuuki008/movie-box

今回は、単にどういったアプケーションかの紹介になります。

主な機能紹介

ジャンルボタンをクリックすることで、TMDBapiを叩いて映画を取得できるようにします。
タイトルなし.gif

人気だけでなく、公開中、高評価、新作公開のapiもあったので、それごとにapiに上記のようなジャンルフィルターに掛けられるようにしています。
スクリーンショット 2020-10-22 20.33.26.png

つぎに認証機能にログインするといいねとリスト作成、追加、削除ができます。
リスト.gif

映画未公開の場合は、一週間前から通知されるようにしました。
notification.gif

映画の検索フォームに入力すると1文字ごとにapiを叩き、検索結果が出力されるようになっています。
suggestion.gif

以上が大体のアプリ紹介になります。

使ったパッケージ

"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/styles": "^4.10.0",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"connected-react-router": "^6.8.0",
"firebase": "^7.21.0",
"history": "^4.10.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-notification-system": "^0.4.0",
"react-redux": "^7.2.1",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.3",
"redux": "^4.0.5",
"redux-action": "^1.2.2",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"router": "^1.3.5",
"thunk": "0.0.1"

ディレクトリ構成

├── AuthWrapper.tsx
├── api.tsx
├── assets
│   ├── actor.css
│   ├── genreList.css
│   ├── images
│   │   ├── logo.svg
│   │   ├── logo_square.svg
│   │   ├── no_image.png
│   │   ├── themoviedb.png
│   │   └── themoviedb_green.svg
│   ├── movieDetail.css
│   ├── pageButton.css
│   ├── profile.css
│   └── search.css
├── components
│   ├── Card
│   │   ├── Cast.tsx
│   │   ├── DefaultCard.tsx
│   │   ├── MovieCard.tsx
│   │   ├── MovieCard2.tsx
│   │   └── Trailer.tsx
│   ├── Modal
│   │   └── FolderList.tsx
│   ├── PageComponent
│   │   ├── Favorite.tsx
│   │   ├── FolderMovie.tsx
│   │   ├── Genre.tsx
│   │   ├── Header.tsx
│   │   └── Release.tsx
│   ├── UIkit
│   │   ├── BoxLabel.tsx
│   │   ├── FormControl.tsx
│   │   ├── LightTooltip.tsx
│   │   ├── MenuButton.tsx
│   │   ├── Notification.tsx
│   │   ├── PageButton.tsx
│   │   ├── PrimaryButton.tsx
│   │   ├── RatingStar.tsx
│   │   ├── ReleaseMovie.tsx
│   │   ├── SelectBox.tsx
│   │   ├── Suggestion.tsx
│   │   ├── TextInput.tsx
│   │   └── index.tsx
│   └── index.ts
├── containers
│   ├── Actor.tsx
│   ├── Auth
│   │   ├── Reset.tsx
│   │   ├── SignIn.tsx
│   │   ├── SignUp.tsx
│   │   └── index.tsx
│   ├── MovieContainer.tsx
│   ├── MovieDetail.tsx
│   ├── MyList.tsx
│   └── index.ts
├── firebase
│   ├── config.tsx
│   └── index.tsx
├── index.css
├── index.tsx
├── react-app-env.d.ts
├── redux
│   ├── actor
│   │   ├── actions.tsx
│   │   ├── operations.tsx
│   │   └── reducers.tsx
│   ├── castlist
│   │   ├── actions.tsx
│   │   ├── operations.tsx
│   │   └── reducers.tsx
│   ├── folder
│   │   ├── actions.tsx
│   │   ├── operations.tsx
│   │   └── reducers.tsx
│   ├── movie
│   │   ├── actions.tsx
│   │   ├── operations.tsx
│   │   └── reducer.tsx
│   ├── movielist
│   │   ├── actions.tsx
│   │   ├── operations.tsx
│   │   └── reducers.tsx
│   ├── selectors.tsx
│   ├── store.tsx
│   ├── trailerlist
│   │   ├── actions.tsx
│   │   ├── operations.tsx
│   │   └── reducers.tsx
│   └── user
│       ├── actions.tsx
│       ├── operations.tsx
│       └── reducers.tsx
├── serviceWorker.ts
└── setupTests.ts

こちらがpackage.jsonの中身になります。
認証機能とdatabase管理は、firebaseを使いました。
UI構築は、material-uiをふんだんに使いました。
今回は、typescriptを使いましたが、まだ勉強し始めのため、any型を多く使ってしまっております!!
申し訳ないです!!

終わりに

今回は、主な機能の実装方法しか載せておりませんので、コードを全て細かく知りたい方はこちらからどうぞ!
ではあと4つ記事を上げていきますので、みていただけると幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

フロントエンドとバックエンドを同時に開発している際の CORS 問題を解消する

はじめに

研究で使用するプロトタイプを React.js と NestJS を使用して開発していた際、CORS が発生して大変だったので共有しておきます。

結論

"proxy": "設定したい URL"package.json にプロキシ設定を記述すると解決できます。

package.json
{
  ... ,
  "proxy": "http://localhost:4000"
}

と記述すると fetch などでリクエストを投げる際に http://localhost:4000/api/v1/ の代わりに /api/v1/ で実行が可能になります。

まとめ

CORS で悩まされて、毎回ものすごく困っていたので機能を見つけたとき感動しました。
初歩的な問題で知っている方も多いと思いますが、少しでも参考になればと思います。

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactひと言メモ(hook)

hookとは

クラスコンポーネントでしか扱えなかったライフサイクル(useEffect)、状態管理(useState)を関数コンポーネントでも扱えるようにしたもの。

useContextもあるよ(Redux使わないときに使うかも)

インポートの仕方

import React , {useEffect, useState} from 'react'

useState

//以下のような1行で設定できる。
const [変数名, 関数名] = useState(初期値)

const [abc, setabc] = useState("")
//ステートabcに文字列aaaをセットできる。
setabc("aaa")

useEffect

useEffect(() => {
    //ここに処理
},[依存値])
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactリンク集

アコーディオンの作成

https://qiita.com/someone7140/items/791256619f2b46365ec4

モーダルの作成

https://mebee.info/2020/04/09/post-6894/

ファイルのアップロードの作成

https://www.aizulab.com/blog/react-dropzone/

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React 学習帳その1

React とは?

React はユーザインターフェイスを構築するための、宣言型で効率的で柔軟な JavaScript ライブラリです。複雑な UI を、「コンポーネント」と呼ばれる小さく独立した部品から組み立てることができます。

↑公式

JSX

Reactで表示したいときに使うものは、JSXという。
Javascriptの拡張した書き方?らしい。

returnでJSXを返して、returnの外ではJavascriptを書ける。
JSX内にJavascriptを埋め込むこともできるJavascript部分を{}で囲む
JSXはreturnの中に複数の要素があるとエラーになるので、

で一つの要素にまとめてあげる。

imgタグに閉じタグ/<img src='画像のURL' />

//Reactをインポート
import React from 'react';
//React.componentを継承するクラスですよー
class App extends React.Component {
//JSXを戻り値とするrenderメソッド
  render() {
    const imgUrl = "画像URL"
return (
      <div>
        <img src={imgUrl}/>
      </div>
    );

お気づきだろうか・・・・:fearful: :fearful: :fearful:
{imgUrl}""で囲っていないことに・・・。
なぜかはわからないが囲むとエラーだった。いつかわかるといいな。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

eslint の設定で import をきれいにする

はじめに

複数人で開発する際に「コーディングルールを揃えたい」というケースはよくあります。
各々が自由に開発していると、import の順番がバラバラになったりして、プルリクエストの際に本質的ではない変更が混在します。

私が香港のスタートアップで開発していたときは、チームメンバー全員が VSCode を利用しており、settings.json に

"editor.codeActionsOnSave": {
    "source.organizeImports": true,
},

を入れることで、統一していました。
しかし、エディタ(は宗教問題なので)を統一したくないケースもあると思いますので、 eslint で出来ればベターだと思います。

今回は、その方法をご紹介します。

実装

今回ご紹介するプラグインは import, unused-imports です。
まずはプラグインをインストールします。

yarn add -D eslint-plugin-import eslint-plugin-unused-imports

次に .eslint.yaml を下記のように変更します。

# .eslint.yaml
env:
  browser: true
  es2021: true
extends:
  - 'plugin:prettier/recommended'
  - 'prettier/@typescript-eslint'
  - 'prettier/react'

parser: '@typescript-eslint/parser'
parserOptions:
  ecmaFeatures:
    jsx: true
  ecmaVersion: 12
  sourceType: module
plugins:
  - react
  - '@typescript-eslint'
  - import
rules: 
  sort-imports: 0
  "import/order":
    - warn
    - groups:
        - builtin
        - external
        - internal
      alphabetize:
        order: asc

筆者の環境では VSCode の設定に、

"editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
},

が入っているので、ファイルを変更して保存した瞬間に import の順番が変わります。

修正前

image.png

修正後

image.png

しかし、このままでは不要な(利用していない)import が残っています。
これも自動で消したいです。

その設定を入れていきます。

# .eslint.yaml
...
plugins:
...
  - unused-imports
rules: 
...
  "@typescript-eslint/no-unused-vars": off
  unused-imports/no-unused-imports-ts: warn
...
修正後

Screen Shot 2020-10-23 at 14.45.21.png

不要な import が消えました。

おわりに

複数人で開発する時に、コードフォーマッタや elinter で記述ルールを揃えると開発速度が一気に加速します。

今回紹介した方法よりも良い方法や、他にも記述改善するためのテクニックがあればぜひ教えてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React Hooks と TypeScript で子コンポーネントに state を渡す方法まとめ

ReactとTypeScriptを使った子コンポーネントにstateを渡す方法をまとめてみました。

別に"Reactと表記するだけ"でも良いのでは?と思うかもしれませんがコードのサンプルを載せることでTypeScript込みの労力みたいなところを感じ取ってもらえればと思いあえて React x TypeScript として取り上げています。

①ダイレクト
②関数経由
③useContext(createContext)

上記のようにおそらくメジャーな手法の3パターンがあります(他の方法などコメントもらいタイ)。

①ダイレクト

stateをそのまま子に渡す方法です。状態を変更するsetStateでコードの方でサンプル載せました。

stateProps.tsx
import React, { FC, useState } from "react";
import Child from "./Child";

const Parent: FC = () => {
  const [stateProp, setStateProp] = useState<string>("Stateです");

  return (
    <>
      <h1>親です</h1>
      <div>{stateProp}</div>
      <Child setStateProp={setStateProp} />
    </>
  );
};

export default Parent;

あえてそのまま受け取った子コンポーネントを載せました。

stateChildSample.tsx
import React, { FC } from "react";

const Child: FC = ({
  setStateProp
}) => {
  return (
    <>
      <h2>Childです</h2>
      <button onClick={() => setStateProp("更新した")}>Stateを更新する</button>
    </>
  );
};
export default Child;

setStateProp をそのまま使えそうですが当然ながらPropsに型を付けないと以下のように怒られます。

Type '{ setStateProp: Dispatch<SetStateAction<string>>; }' is not assignable to type 'IntrinsicAttributes & { children?: ReactNode; }'.
  Property 'setStateProp' does not exist on type 'IntrinsicAttributes & { children?: ReactNode; }'.ts(2322)

要するに Dispatch< SetStateAction< string > > で型を付けなさいということです。

ちなみに Dispatch と生成された SetStateAction の型はimportしてこないといけません。下のようなコードで更新できるようになりました。

stateChildSample改.tsx
import React, { FC, Dispatch, SetStateAction } from "react";

const Child: FC<{ setStateProp: Dispatch<SetStateAction<string>> }> = ({
  setStateProp
}) => {
  return (
    <>
      <h2>Childです</h2>
      <button onClick={() => setStateProp("更新した")}>Stateを更新する</button>
    </>
  );
};
export default Child;

codesandbox: https://codesandbox.io/s/state-typescript-update-kn8kj?fontsize=14&hidenavigation=1&theme=dark

デメリット

お手軽ですが型付けが少しややこしいと思いました。
またReact公式チュートリアルでもstateはできる限り親の方でキープした方がスッキリかけて単一方向のデータフローの原則を守りやすいと思います。

②関数経由

ポピュラーな方法だと思います。

funcParentSample.tsx
import React, { FC, useState } from "react";
import Child from "./Child";

const Parent: FC = () => {
  const [stateProp, setStateProp] = useState<string>("Stateです");
  const updateState = (): void => setStateProp("更新した");

  return (
    <>
      <h1>親です</h1>
      <div>{stateProp}</div>
      <Child updateState={updateState} />
    </>
  );
};

export default Parent;

型付けも関数の型を渡してあげます。

funcChildSample.tsx
import React, { FC } from "react";

const Child: FC<{ updateState: () => void }> = ({ updateState }) => {
  return (
    <>
      <h2>Childです</h2>
      <button onClick={() => updateState()}>Stateを更新する</button>
    </>
  );
};
export default Child;

codesandbox: https://codesandbox.io/s/func-state-update-b6ohz?fontsize=14&hidenavigation=1&theme=dark

③useContext(createContext)

useContextとはpropsを使わずに子から孫まで値を渡すことができるHooksの一つです。親より以下のグローバル変数といったイメージとも言い換えられます。

親側は "react" ライブラリから { createContext } を引っ張っています。子や孫側では { useContext } を引っ張ります。

親側ではcreateContextを格納した変数をコンポーネントとして使いますが < Context.Provider > といった形で .Provider で値をJSX上に呼び出すことができます。

parent.tsx
import React, { FC, createContext, useState } from "react";
import Child from "./Child";

export type ContextType = string;
export const Context = createContext<ContextType>(""); //ここで初期化

const Parent: FC = () => {
  const [state, setState] = useState(
    "私はContextです。propsで渡してもらっていません。"
  );
  const updateContext = () => setState("Contextを更新したよ。");

  return (
    <>
      <h1>親です</h1>
      <button onClick={updateContext}>contextを更新するボタン</button>
      <Context.Provider value={state}>
        <Child />
      </Context.Provider>
    </>
  );
};

export default Parent;

次は子コンポーネントですが、孫(ChildChild.tsx)、ひ孫(ChildChildChild.tsx)もほぼ同じコードになっており { useContext } をReactライブラリから引っ張ってきています。

加えて import { Context } from "./Parent"; によってcreateContextで初期化した変数を引っ張っており、 { useContext } と一緒に使います。

child.tsx
import React, { FC, useContext } from "react";
import ChildChild from "./ChildChild";
import { Context } from "./Parent";

const Child: FC = () => {
  const ChildContext = useContext(Context);

  return (
    <>
      <h2>Childです</h2>
      <p>{ChildContext}</p>
      <ChildChild />
    </>
  );
};
export default Child;

親側でクリックするとpropsで渡していないですが子にも反映されるようになります。

<button onClick={updateContext}>contextを更新するボタン</button>

codesandbox:https://codesandbox.io/s/props-usecontext-6x1dj?fontsize=14&hidenavigation=1&theme=dark

useContextに関しては以前、5分でわかる useContext の使い方【TypeScriptまで】の記事で上記コードをさらに噛み砕いて詳細に解説しています。

useContextのデメリット

propsリレーの煩わしさや複雑化の解決策になります。一方で子コンポーネントから変更を加えたいケースでは工夫が必要となるため、①や②の手法の方が手っ取り早いです。
子からstateを変更したい場合は【React + Typescript】useContext の値を子コンポーネントから更新が参考になります。

まとめ

基本的には関数かuseContextが無難かと。他のHooksとの兼ね合いや複雑さに応じて渡し方を最適化できていきたいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React学習日記② ~Firebaseへのデプロイ~

はじめに

今回は、create-react-appで作成したReactアプリをFirebaseへデプロイ際の手順を簡単にまとめます。

※こちらの記事はReact初学者の備忘録・アウトプットを目的とした記事です。理解が浅い部分もありますが、ご容赦ください。何かしら参考になれば幸いです。

そもそもFirebaseとは

  • FirebaseとはGoogleが提供するアプリのプラットフォームのことです。様々なプロジェクトのデータベースがこのクラウド環境に設置されているわけです。
  • Firebaseはクラウド環境にデータベースなどを設置して、インターネット経由でアクセスして利用できるようにしています。サーバー側にプログラムを用意する必要がないため、Reactのようなクライアント側だけしかもたないWebアプリでも使用されます。

開発手順

01. Firebaseにプロジェクトを作成する。

  • まずFirebaseコンソールにアクセスします。 そこでプロジェクトの作成をクリックします。 7D712CF4-2816-4CE1-8C28-9F0C1080F699.png

- プロジェクトに名前をつけて、プロジェクトを作成します。
5A06A194-7406-4189-BC57-D5C9D403819C_1_105_c.jpeg

02. リソースのロケーションを設定する。

  • 左再度のメニューバーから、歯車マークから「プロジェクトを設定」を選択します。
    image.png

  • 遷移先のページで、「デフォルトのGCPのリソースロケーション」を選択します。(デフォルトでは未設定となっています。)
    image.png

  • クラウドリソースのロケーションに「asia-northeast1」に設定します。

03. 使用するプラットフォームを選択

  • 今回はwebアプリを作成するので、「</>」のマークを選択します。
    image.png

  • アプリのニックネームを決めて、入力します。

  • 今回はFirebase Hostingの設定にもチェックを入れます。
    B0CDBB80-2A91-42B7-8307-2407F2BE1D60_4_5005_c.jpeg

04. データベースを作成

  • それぞれのアプリにあったデータベース("cloud fire store"か"real time database")を選択するのですが、公式ドキュメントを参考にしてください。
  • 今回はfire storeを利用するので、サイドバーから、cloud fire storeを選択します。
  • すると、以下のページに遷移するので、「データベースの作成」をクリックしてください。
    image.png

  • 本番モードかテストモードかを聞かれますが、firestore.rulesというファイルで設定を上書きするので、どちらでもいいです。

05. 各コマンドの実行

  • firebase toolsをグローバルインストールします。ターミナルで以下のコマンドを実行します。(一度でも実行したことがあれば、実行する必要はありません)
% npm install -g firebase-tools
  • firebaseをインストール
% npm install —save firebase
  • firebaseにログイン
% firebase login

googleアカウントを選択して許可します。

06.firebase initを設定

% firebase init

Witch Firebase CLI features do you want to set up for this folder?と聞かれますが、今回は
◯Firestore
◯Functions
◯Hosting
にチェックを入れます。
(上下の方向キーで移動して、スペースキーでチェックを入れます。選択したEnterキーで次へ進みます。)

 

  • 続いてPlease select an option:と聞かれます

今回はプロジェクトを作ってあるので、
use an existing project(既存のプロジェクトを使う)を選択して、プロジェクトを選択します。

  • What file should be used for Firestore Rules?(firestoreのルールを決めるファイルはこれでいいですか?)と聞かれるので、デフォルトでEnterを押します。

  • What file should be used for Firestore indexes?(firestoreのindexを決めるファイルはこれでいいですか?)と聞かれるので、こちらもyesで通します。

  • What langage would you like to use to write Cloud Functions?(cloud functionsをどの言語で書きますか?)と聞かれるので、JavaScriptかtypeScriptかを選択してEnterを押します。(今回はTypeScriptを選択しました。)

  • Do you want to use TSLint to catch probable bugs and enforce style?と設定を聞かれるので、yesで通します。

  • Do you want install dependencies with npm now?(npmで依存関係のあるものをインストールしますか?)と聞かれるので、yesで通します。

  • What do you want to use as your public directory?(どのパブリックディレクトリをつかいますか?)と聞かれます。
    create-react-appでは、後ほど生成するbuildファイルを本番環境用に用意するので、「build」を指定してEnterを押します。

  • Configure as a Single-page app?(SPAとして設定したいですか?)と聞かれますが、SPAを作る人はyesをにしてEnterを押します。

07. firestore.rulesの設定

  • エディタに戻り、firestore.rulesファイルを開き、最低限のセキュリティ設定を行います。
firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read;
      allow write: if request.auth.uid != null;
    }
  }
}

こちらは、誰でもデータベースを読み込むことはできるが、書き込みは認証されたユーザーでないとできないという設定を加えることができました。

buildフォルダを生成

  • 本番用のbuildフォルダを生成するために、以下のコマンドを実行します
% npm run build
  • コンパイルエラーを防ぐためにfunctoins/src/index.jsのimportを一旦コメントアウトします。
functions/src/index.ts
// import * as functions from 'firebase-functions';

08. Firebaseへデプロイ

  • ターミナルへ戻り、以下のコマンドを実行します。
% firebase deploy

成功すると、下の方にHosting URLというものが現れますが、こちらが本番環境のURLになります。

firebase deploy時のエラー解決

deploy時に400番のエラーを返されることがあります。
こちらは、firebaseの料金プランが違うためです。firebaseの料金プランを無料プランから、Blaze(従量課金制)に変更しなくてはなりません。基本的に個人で開発する程度のデータ量なら、無料で使えるはずですが、公式ドキュメントを参考にしてプランを変更してみてください。

おわりに

大分冗長な説明になってしまいましたが、最後までお付き合いくださりありがとうございました。
修正点等ありましたら、教えてくださると助かります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

シャニマスのカードを検索できるサイトを作ってちょっとバズったけど公式に検索機能がついて終わった話

アイドルマスターシャイニーカラーズ(シャニマス)遊んでますか?

5月末に公開した「シャニマスsSSR検索」について、数ヶ月経過しましたがせっかく作ったので顛末(?)の記事を書こうと思います。
コードらしいコードはでてきません。
(ゲームに直接関与するツールではありませんが、権利的にグレー?な部分があると思います。連絡を取りたい権利者の方はTwitter@nok0714にDMをください)

サイトはこちらです↓
https://shiny-support-search.netlify.app/

背景

シャニマスは、2018年の4月24日にサービスを開始したアイドルマスター(アイマス)シリーズの末っ子です。

ソシャゲ・音ゲーのスタイルを取る他のスマホ向けアイマスとは異なり、アイドル育成&ライブ対戦(公式のジャンル説明)ゲームとなっています。
ジャンルの通りゲームは大まかに2パートに別れています。

  1. アイドル育成: プロデュースでシナリオを見つつ好きなキャラを育てる
  2. ライブ対戦: ↑で育てたキャラを5人選んで編成したユニットで、他のプレイヤーのユニットと対戦する

2.では3人の審査員(Vo/Da/Vi)に効果的なアピールをたくさん行ったほうが勝ち(ざっくり)というスタイルになっています。
効果的なアピールをするには、自分の立てた戦略をうまく回せるような編成が必要になります。

そのためには1.の育成でいかに強いアイドルを育てられるか、が重要になります。
1.の育成パートでは、育成対象のキャラ(プロデュースアイドル)に対して、5人の補助キャラ(サポートアイドル・ゲストアイドル)を参加させられます。

編成画面.png

サポートアイドルにはそれぞれスキルが設定されています。例↓

  • 一緒にダンスレッスンをするとダンスのステータスがたくさん伸びる「ダンスマスタリーDa」
  • おやすみ(体力回復)のときにたくさん回復する「おやすみブースト」
  • 同じユニットのキャラが同じレッスンやお仕事にいるとメンタル(対戦時の体力)が伸びる「ユニットマスタリーMe」

シナリオを勝ち抜いたり、強い対戦用アイドルを作ったりするには、このスキルをうまく編成することが重要な要素のひとつになります。
スキルはカードごとに違うものが設定されています。
シャニマスでは現在6ユニット・23人のキャラがいます。
ユニットの登場時期によって異なりますが、各キャラに10枚程度はカードがあります(レアリティSSRとSRを合わせるとこれくらいかと)。

2020年5月当時でサポートSSRカードは90枚ほどありました。
また、当時は新シナリオであるG.R.A.D.が実装され、初期シナリオの W.I.N.G.編、1周年時に実装されたファン感謝祭編の3本立てとなったところでした。
それぞれのシナリオでどのようなスキルが重要になるかが異なり、ファン感謝祭とG.R.A.D.ではスキル以外にアイディア、ひらめきと呼ばれる要素が追加されました。
(例: オーディションを行わないファン感謝祭では、オーディションに勝つと特定のステータスが追加で伸びるオーディションマスタリーは無駄)

当時はシャニマス側にキャラの検索機能がなく、いい加減覚えられないなーと思った & 2周年で入ってきた初心者さんが「あのスキルは誰が持ってるんだー!」と困っているだろうと考えたので、Reactの勉強も兼ねて、指定したスキルを持つカードを検索できるWebアプリを書いてみることにしました。
(Twitterで「こんなんあったら便利かなー」って言ったらフォロワーに後押しされたので、それも原動力になりました)

実装

React使おう

今どきっぽいWebの書き方は全然知らなかったのですが、自分が管理に関わるサイトで使っているGatsbyを見て「おお~こうやるのか~」と思ったので、Reactをベースにしてみることにしました。
で、どうもReactで何かを作るならCreate-react-appがいいらしいと聞き、これを使ってみることにしました。
Create-react-appでは、最低限の環境をお膳立てして、Reactを使ったWebアプリを製作できるようにしてくれます。

で、なるべくなら画面をかっこよくしたいということで、UIフレームワーク的なものも入れてみることにしました。
コンポーネントの充実具合(特に"カード"が良かった!)でMaterial UIを選びました。

まずはなにか表示できないかなと思って、適当に作ってみたのがこれ。
最初は、上の方の画像で示したプロデュースユニットを再現しようとしていました。

プロトofプロト.png

同じものが15枚並んでいますが、個々のものはカードのコンポーネントチップのコンポーネントを組み合わせて作りました。

このあたりで、「自前で好き勝手にコンポーネントを組み合わせて部品として名前をつけて自由に使えるってスゲー!」ってなってました。
ついでに、npm installで欲しい物を持ってこられるのもすごいし、ソースを変えればホットリロードされるのもすごい!!って喜んでました。
あとなにかしくじったときに意外と丁寧にエラーを教えてくれて「意外とやるやん」となっていました。

画面構成を考える

↑と前後して「どういう画面構成にするか」について考え始め、しばらくお絵かきをしました。
変遷はこんな感じでした↓

その1 その2 その3
1.JPG 2.JPG 3.JPG

「その1」はシャニマスのプロデュースユニット編成画面とサポートアイドル一覧画面をガッチャンコしたようなものですね。
「その2」の上側では2枚のカードを比較する構成でした。下側でかなり画面が固まってきて、左ペインで持っているスキルを選択し、右ペインで検索結果を表示する形となっています。
実際、上のプロトタイプはその1とその2の途中くらいで作ったものだと思います。
で、結局「その3」(落書きは無視で…)で画面構成が固まりました。

本格的に作る

半年近く前なのでもうどういう順序で作ったか覚えていませんが、左ペイン・右ペインをページ全体で包む構造にしました。
実際にはMaterial UIのサイトを眺めてDrawerが使えそう!ということでこれを使いました。

で、順にお絵かき通りのものを作っていきました。

  • 右ペイン
    • カード: プロトタイプのものを引っ張ってきて、propsで渡したデータを表示できるように。
    • カードを並べる部分: Gridコンポーネントにpropsでカード一覧を入れられるように。
  • 左ペイン
    • 感謝祭アイディア・G.R.A.D.ひらめき: Radioを使用。
    • マスタリー・その他スキル: カードに使ったChipsをクリックできるようにして表示。

ガワはできたのですが、propsstateとイベントについてよくわかっていなかったので、左側で絞り込んだスキルをどうやって右側に渡すんだろうとしばらく悩んでいました。
「スキルの絞り込みパネルでonClickはつけられるけどどうやって親にこれを渡すんだ?」「親から渡ってきたとして右側ってもう描画されてるはずじゃんどうやって変更するの???」みたいな状態でした。

結局色々やっているうちに「親がpropsで送りつけた関数を子のonClick内で叩くのか」「React.useStateで作った変数をpropsとして渡して、それに合わせて個々のカードの描画するしないを決めれば勝手に描画し直すんだ」とわかりました。
完全に雰囲気で書いているので合っているのか本当にわかりませんが、とりあえず動きました。

そうしてできた初期のスクショがこちら。

初期スクショ.png

この時点でデータがかけらも入っていなかったので、画像を集めつつ(持っていなかったカードの画像を提供してくれた友達に感謝)、シャニマス攻略Wiki(wikiwikiのやつ)のデータにお世話になりつつ、表示用データを整えてついに完成しました。

202005241950_resized.png

ファイルの作成日を見た結果、どうやら5月20日にプロトタイプをゴニョゴニョしはじめて、5月22日に本実装を始め、5月23日に公開用バージョンができいたようなので、かなり一気に書いたんだなあ。

公開

公開するからにはWebサーバが必要だぞ、となったのですが↑の実装で気力が尽きていて、自分で建てるのも嫌だったのでいまどきらしくNetlifyにおまかせしました。
作ったもの自体はすべてビルドバンドルで完結しており、動的に何かをする必要もなかったので(静的サイトって言うんですかね)Netlifyで十分でした。

GitHubのプライベートリポジトリにコードをpushして、Netlifyでそのリポジトリと連携し、どのブランチをビルド対象にするか、ビルドコマンドはなにか等々を設定すればそれで完了だったので非常に楽でした。
(URLもサブドメイン部分なら好きに変えられるし!)

そんなこんなで公開し、Twitterで作ったよ!ツイートを出しました。
それがこれ。

当時はシャニマス本家で一切キャラの絞り込みができなかったので、それなり(?)にウケました。
通知止まらんwを初めて体験しました。
ただ、イマドキのサイトなのにスマホ対応が全然できていなくて、そのあたりは失敗だったなあと思っています。
今は更新しているのでこうなりませんが、当時はデスクトップの表示がそのままスマホでも出ていて、widthの指定がテキトーだったので、検索にマッチするカードが3枚?を切ると表示がイカれていました↓

読み込み直後 マッチなし
初期1.png 初期2.png

ゴミ化

RTやlikeをしてくれた人をちょくちょく覗いてみて「便利」「公式にくれ」って発言を見て鼻高々になったり、リプライで「便利ですね!」とお言葉をもらい天狗になっていたのですが、終焉はすぐに訪れました。
7月1日に「公式側に検索機能が実装される」という告知が行われたのです。ひと月少々の命でした。
実際に7月10日の更新で絞り込みができるようになり、サポートスキル(この記事でスキルと呼んでいるもの)・ライブスキル(サポートアイドルがオーディションに参加した際に利用できるスキル)・未読コミュの有無・登場種類(期間限定、イベント配布など)といった条件でキャラを探せるようになりました。
公開してるサイトより遥かに便利です。負け惜しみがこれ↓

とはいえせっかく作ったのに放置するのももったいない。
ということで、自分が「ほしいな~」と思った機能を付け足すことにしました。

機能追加

いまどんな感じかは https://shiny-support-search.netlify.app/ を見てください。

最初に公開したときにマスタリー以外での絞り込みの機能はないの?と訊かれていたので、その機能の更新から始めました。
機能を追加する、つまりgitのブランチを勉強できる!ということで、見様見真似でブランチを切ってみました。

  • master: 公開用
  • develop: 開発用よろづ
  • feature/x: xの機能開発用
  • hotfix/x: なんか急務の更新用
  • package-update: npmで入れているパッケージの更新
  • data-update: シャニマス側で新しいカードが追加された際のデータ追加

で、細々したコード変更はdevelopで行い、それなりに量のありそうな作業はfeatureで行ってからdevelopにマージしました。
我流でやったので合っているのかもよくわかりません。
結果、以下の更新を行いました。

  • マスタリー以外のスキルで絞り込み(5/24): リプライで指摘されて。公開した最初のバージョンではマスタリーでしか絞り込めませんでした。実は準備はしていたので一瞬でした。
  • スマートフォン対応(9/10): 小さい画面では検索条件ペインを開閉できるようにしました。3ヶ月以上開いていますが、この間は細かい表示調整をしていました。
  • キャラ別の絞り込み(9/28): スキルの絞り込みと同じ形で、キャラ単位の絞り込みを実装しました。
  • アコーディオンメニュー(9/28): 検索条件が増えてきたので、折り畳めるようにしました。キャラ別の絞り込みと同時です。
  • OGPプレビュー機能(9/30): Twitterやfacebookでリンクをツイートするとリッチな感じになります。なかなかの曲者で、こいつのためにステージング環境を用意することになりました(それまではローカルで挙動を見て問題なさそうなら公開)。
  • 画像だけモード(10/3): 右ペインのカード一覧からあえてスキルを非表示にできる。シャニマスの美しいイラストを眺めたい欲望で生まれました。

そんな感じで更新を続けて、途中でサイト名を変えたりしましたが、(これも適当ですが)初期公開でv0.1.0だったバージョンが10月22日現在ではv0.4.2になりました。

ちなみにスマホ対応直後はこんな感じで、メニューが開閉できます(カードの一覧性が下がった感じはしますが)

読み込み直後 メニューオープン時
スマホ対応1.png スマホ対応2.png

このサイトの実装や更新を通して勉強できたこと

意外とまともに開発をしたことがなかったので、色々と勉強になりました。

  • Reactを使ったフロントエンド?開発(ベタにHTMLを書くなんてもうできないなー)
  • Material-UIを使ったUIづくり(スタイリングはあまりいじっていませんが、かなりコンポーネントが充実していて、組み合わせればいけそう感がよかったです)
  • Netlifyを使ったCD/CI(git pushだけでいいんだから楽ちん)
  • ちゃんとブランチを切ったGit開発(複数の機能を同時に、ということがなかったので結局一本道ですが)

今後も自分の思うままに更新していきたいなーという感じです。例えば

  • 画像非表示モードで一覧性を上げる
  • サービスワーカーを使ったオフライン動作
  • サーバープッシュ(できるのだろうか?)
  • スキル以外の性能でも検索
  • シャニマス攻略Wikiへのリンク
  • レアリティSRの追加(枚数が増えすぎるのであまりやりたくない)
  • 内部のリファクタリング
  • URLパラメタから検索条件を取得して、Twitterでのリンク共有から絞り込み済みの状態にアクセス(「どうしよー」「こん中から選べ ほらリンクだぞ」ってできたら便利そう)

などですかね。

最後に

かわいい(けどそれだけじゃない)キャラクター・美麗なイラスト・よく練られたストーリー・引きやすいガシャにぶん殴られるアイドルマスターシャイニーカラーズをぜひ遊んでみてください。
PCブラウザでも遊べます。こちらから→ https://shinycolors.enza.fun/
スマートフォンアプリ版もあります。PCと同じセーブデータを使えるので、外ではスマホ・家でPCという感じでプレイ環境を変えられます!


P.S. ゲーム大好き・自分に自信が持てないけど変わりたい・双子のお姉ちゃん・顔が良い眠り姫こと大崎甜花ちゃんをよろしくおねがいします。

甜花.jpg

2020年10月22日現在、左下の【トゥインクル・トゥインクル】を入手できるイベント「ミルキィウェイ61-世界と世界がまじわる夜に-」が復刻開催されています。
これから始めるという方でも、プロデュースを2回終えると(早いシーズンで負けてしまってもOK)イベントポイントで1枚は入手できます!!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む