DoneJS StealJS jQuery++ FuncUnit DocumentJS
6.0.1
5.33.2 4.3.0 3.14.1 2.3.35
  • About
  • Guides
  • API Docs
  • Community
  • Contributing
  • Bitovi
    • Bitovi.com
    • Blog
    • Design
    • Development
    • Training
    • Open Source
    • About
    • Contact Us
  • About
  • Guides
    • getting started
      • CRUD Guide
      • Setting Up CanJS
      • Technology Overview
    • topics
      • HTML
      • Routing
      • Service Layer
      • Debugging
      • Forms
      • Testing
      • Logic
      • Server-Side Rendering
    • app guides
      • Chat Guide
      • TodoMVC Guide
      • TodoMVC with StealJS
    • beginner recipes
      • Canvas Clock
      • Credit Card
      • File Navigator
      • Signup and Login
      • Video Player
    • intermediate recipes
      • CTA Bus Map
      • Multiple Modals
      • Text Editor
      • Tinder Carousel
    • advanced recipes
      • Credit Card
      • File Navigator
      • Playlist Editor
      • Search, List, Details
    • upgrade
      • Migrating to CanJS 3
      • Migrating to CanJS 4
      • Migrating to CanJS 5
      • Migrating to CanJS 6
      • Using Codemods
    • other
      • Reading the API Docs
  • API Docs
  • Community
  • Contributing
  • GitHub
  • Twitter
  • Chat
  • Forum
  • News
Bitovi

Playlist Editor

  • Edit on GitHub

Learn how to use YouTube’s API to search for videos and make a playlist. This makes authenticated requests with OAuth2. It uses jQuery++ for drag/drop events. It shows using custom attributes and custom events. This advanced guide takes an hour to complete.

The final widget looks like:

See the Pen Playlist Editor (Advanced) [Finished] by Bitovi (@bitovi) on CodePen.

To use the widget:

  1. Click Sign In to give access to the app to create playlists on your behalf.
  2. Type search terms in Search for videos and hit enter.
  3. Drag and drop those videos into the playlist area (Drag video here).
  4. Click Create Playlist.
  5. Enter a name in the popup.
  6. Navigate to your YouTube channel to verify the playlist was created.

START THIS TUTORIAL BY CLICKING THE “EDIT ON CODEPEN” BUTTON IN THE TOP RIGHT CORNER OF THE FOLLOWING EMBED::

See the Pen Playlist Editor (Advanced) [Starter] by Bitovi (@bitovi) on CodePen.

The following sections are broken down into:

  • Problem — A description of what the section is trying to accomplish.
  • Things to know — Information about CanJS that is useful for solving the problem.
  • Solution — The solution to the problem.

The following video goes through this recipe:

Set up CanJS and Load Google API

The problem

In this section, we will:

  1. Load Google’s JS API client, gapi, and initialize it to make requests on behalf of the registered "CanJS Playlist" app.
  2. Set up a basic CanJS application.
  3. Use the basic CanJS application to show when Google’s JS API has finished loading.

What you need to know

  • The preferred way of loading Google’s JS API is with an async script tag like:

    <script async defer src="https://apis.google.com/js/api.js"
      onload="this.onload=function(){}; googleScriptLoaded();"
      onreadystatechange="if (this.readyState === 'complete') this.onload();">
    </script>
    

    The async attribute allows other JS to execute while the api.js file is loading. Once complete, this will call a googleScriptLoaded function.

  • Once api.js is loaded, it adds the gapi object to the window. This is Google’s JS API. It can be used to load other APIs that extend the gapi library.

    The following can be used to load the OAuth2 GAPI libraries:

    gapi.load("client:auth2", completeCallback);
    

    Once this functionality is loaded, we can tell gapi to make requests on behalf of a registered application. In this case, the following keys enable this client to make requests on behalf of the "CanJS Playlist" application:

    gapi.client.init({
      apiKey: "AIzaSyAbHbOuFtJRvTX731PQXGSTy59eh5rEiE0",
      clientId: "764983721035-85cbj35n0kmkmrba10f4jtte8fhpst84.apps.googleusercontent.com",
      discoveryDocs: [ "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest" ],
      scope: "https://www.googleapis.com/auth/youtube"
    }).then( completeCallback )
    

    To use your own key, you can follow the instructions here. This is not required to complete this guide.

  • Instead of callbacks, CanJS favors Promises to manage asynchronous behavior. A promise can be created like:

    const messagePromise = new Promise(function(resolve, reject) {
      setTimeout(function() {
        resolve("Hello There");
      }, 1000);
    });
    

    resolve should be called once the promise has a value. reject should be called if something goes wrong (like an error). We say the messagePromise resolves with "Hello There" after one second.

    Anyone can listen to when messagePromise resolves with a value like:

    messagePromise.then(function(messageValue) {
      messageValue //-> "Hello There"
    });
    

    CanJS can use promises in its can-stache templates. More on that below.

  • A basic CanJS application is a live-bound template (or view) rendered with the component’s props.

    import { StacheElement } from "can";
    
    class MyApp extends StacheElement {
      static view = `<h1>{{ message }}</h1>`;
    
      static props = {
        message: "Hello World"
      };
    }
    
    customElements.define("my-app", MyApp);
    
  • Mount a can-stache-element by its custom tag like:

    <my-app></my-app>
    
  • Use {{# if(value) }} to do if/else branching in can-stache.

  • Promises are observable with can-stache. Given a promise somePromise, you can:

    • Check if the promise is loading like: {{# if(somePromise.isPending) }}.
    • Loop through the resolved value of the promise like: {{# for(item of somePromise.value) }}.
  • ObservableObject can be used to define the behavior of observable objects like:

    import { ObservableObject } from "can";
    
    class Type extends ObservableObject {
      static props = {
        message: String
      };
    }
    
  • props are ObservableObject like properties used to control the behavior of a custom element.

    import { StacheElement } from "can";
    
    class PlaylistEditor extends StacheElement {
      static view = `...`;
      static props = {
        message: String
      };
    }
    
    customElements.define("playlist-editor", PlaylistEditor);
    
  • ObservableObjecs can specify a default value and a type:

    import { StacheElement } from "can";
    
    class PlaylistEditor extends StacheElement {
      static view = `...`;
      static props = {
        message: { type: String, default: "Hello World" }
      };
    }
    
    customElements.define("playlist-editor", PlaylistEditor);
    

The solution

Update the HTML to:

Note: use your own clientId if you use this code outside this guide and CodePen.

<playlist-editor></playlist-editor>

<script src="https://code.jquery.com/jquery-2.2.4.js"></script>
<script src="https://unpkg.com/jquerypp@2/dist/global/jquerypp.js"></script>

<script>
window.googleApiLoadedPromise = new Promise(function(resolve) {
    window.googleScriptLoaded = function() {
        gapi.load("client:auth2", function() {
            gapi.client.init({
                apiKey: "AIzaSyBcnGGOryOnmjUC09T78VCFEqRQRgvPnAc",
                clientId: "764983721035-85cbj35n0kmkmrba10f4jtte8fhpst84.apps.googleusercontent.com",
                discoveryDocs: [ "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest" ],
                scope: "https://www.googleapis.com/auth/youtube"
            }).then(resolve);
        });
    }
});
</script>

<script async defer src="https://apis.google.com/js/api.js"
    onload="this.onload=function(){}; googleScriptLoaded();"
    onreadystatechange="if (this.readyState === 'complete') this.onload();">
</script>

Update the JavaScript tab to:

import { StacheElement } from "//unpkg.com/can@6/core.mjs";

class PlaylistEditor extends StacheElement {
    static view = `
      {{# if(this.googleApiLoadedPromise.isPending) }}
          <div>Loading Google API…</div>
      {{ else }}
          <div>Loaded Google API</div>
      {{/ if }}
    `;

    static props = {
        googleApiLoadedPromise: {
            get default() {
                return googleApiLoadedPromise;
            }
        }
    };
}

customElements.define("playlist-editor", PlaylistEditor);

Sign in and out

The problem

In this section, we will:

  1. Show a Sign In button that signs a person into their Google account.
  2. Show a Sign Out button that signs a person out of their Google account.
  3. Automatically know via Google’s API when the user signs in and out, and update the page accordingly.
  4. Show a welcome message with the user’s given name.

What you need to know

  • Once the Google API has been fully loaded, information about the currently authenticated user can be found in the googleAuth object. This can be retrieved like:

    googleApiLoadedPromise.then(function() {
      const googleAuth = gapi.auth2.getAuthInstance()
    });
    

    With googleAuth, you can:

    • Know if someone is signed in: googleAuth.isSignedIn.get()
    • Sign someone in: googleAuth.signIn()
    • Sign someone out: googleAuth.signOut()
    • Listen to when someone’s signedIn status changes: googleAuth.isSignedIn.listen(callback)
    • Get the user’s name: googleAuth.currentUser.get().getBasicProfile().getGivenName()
  • ES5 Getter Syntax can be used to define a component property that changes when another property changes. For example, the following defines an signedOut property that is the opposite of the signedIn property:

    import { StacheElement } from "can";
    
    class PlaylistEditor extends StacheElement {
      static view = `...`;
      static props = {
        signedIn: Boolean,
        get signedOut() {
          return !this.signedIn;
        }
      };
    }
    
    customElements.define("playlist-editor", PlaylistEditor);
    
  • Use asynchronous getters to get data from asynchronous sources. For example:

    import { StacheElement } from "can";
    
    class MyApp extends StacheElement {
      static view = `...`;
      static props = {
        property: {
          async(resolve) {
            apiLoadedPromise
              .then(function() {
                resolve(api.getValue());
              });
          }
        }
      };
    }
    
    customElements.define("my-app", MyApp);
    
  • can-stache-element connected hook can be used to perform initialization behavior. For example, the following might initialize googleApiLoadedPromise:

    import { StacheElement, type } from "can";
    
    class PlaylistEditor extends StacheElement {
      static view = `...`;
      static props = {
        googleApiLoadedPromise: type.Any,
      };
      connected() {
        this.googleApiLoadedPromise = googleApiLoadedPromise;
      }
    }
    
    customElements.define("playlist-editor", PlaylistEditor);
    
  • ObservableObject’s listenTo lets you listen on changes in a component. This can be used to change values when other values change. The following will increment nameChange everytime the name property changes:

    import { StacheElement } from "can";
    
    class MyApp extends StacheElement {
      static view = `...`;
      static props = {
        name: String,
        nameChange: Number
      };
      connected() {
        this.listenTo("name", () => {
          this.nameChange += 1;
        });
      }
    }
    

    Note: EventStreams provide a much better way of doing this. Check out can-define-stream-kefir.

  • Use on:EVENT to listen to an event on an element and call a method in can-stache. For example, the following calls sayHi() when the <div> is clicked.

    <div on:click="sayHi()"> <!-- ... --> </div>
    

The solution

Update the JavaScript tab to:

import { StacheElement, type } from "//unpkg.com/can@5/core.mjs";

class PlaylistEditor extends StacheElement {
    static view = `
        {{# if(this.googleApiLoadedPromise.isPending) }}
            <div>Loading Google API…</div>
        {{ else }}
            {{# if(this.signedIn) }}
                Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
            {{ else }}
                <button on:click="this.googleAuth.signIn()">Sign In</button>
            {{/ if }}
        {{/ if }}
    `;

    static props = {
        googleApiLoadedPromise: {
            get default() {
                return googleApiLoadedPromise;
            }
        },

        googleAuth: {
            async(resolve) {
                this.googleApiLoadedPromise.then(() => {
                    resolve(gapi.auth2.getAuthInstance());
                });
            }
        },

        signedIn: Boolean,

        get givenName() {
            return (
                this.googleAuth &&
                this.googleAuth.currentUser
                    .get()
                    .getBasicProfile()
                    .getGivenName()
            );
        }
    };

    connected() {
        this.listenTo("googleAuth", ({ value: googleAuth }) => {
            this.signedIn = googleAuth.isSignedIn.get();
            googleAuth.isSignedIn.listen(isSignedIn => {
                this.signedIn = isSignedIn;
            });
        });
    }
}

customElements.define("playlist-editor", PlaylistEditor);

Search for videos

The problem

In this section, we will:

  1. Create a search <input> where a user can type a search query.
  2. When the user types more than 2 characters, get a list of video search results and display them to the user.

What you need to know

  • Use value:bind to setup a two-way binding in can-stache. For example, the following keeps searchQuery and the input’s value in sync:

    <input value:bind="searchQuery">
    
  • Use gapi.client.youtube.search.list to search YouTube like:

    const googlePromise = gapi.client.youtube.search.list({
      q: "dogs",
      part: "snippet",
      type: "video"
    }).then(function(response) {
      response //-> {
               //     result: {
               //       items: [
               //         {
               //           id: {videoId: "ajsadfa"},
               //           snippet: {
               //             title: "dogs",
               //             thumbnails: {default: {url: "https://example.com/dog.png"}}
               //           }
               //         }
               //       ]
               //     }
               //   }
    });
    
  • To convert a googlePromise to a native Promise use:

    new Promise(function(resolve, reject) {
      googlePromise.then(resolve, reject);      
    })
    

The solution

Update the JavaScript tab to:

import { StacheElement } from "//unpkg.com/can@6/core.mjs";

class PlaylistEditor extends StacheElement {
    static view = `
        {{# if(this.googleApiLoadedPromise.isPending) }}
            <div>Loading Google API…</div>
        {{ else }}
            {{# if(this.signedIn) }}
                Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
            {{ else }}
                <button on:click="this.googleAuth.signIn()">Sign In</button>
            {{/ if }}

            <div>
                <input value:bind="this.searchQuery" placeholder="Search for videos">
            </div>

            {{# if(this.searchResultsPromise.isPending) }}
                <div class="loading">Loading videos…</div>
            {{/ if }}

            {{# if(this.searchResultsPromise.isResolved) }}
                <ul class="source">
                    {{# for(searchResult of this.searchResultsPromise.value) }}
                        <li>
                            <a href="https://www.youtube.com/watch?v={{ searchResult.id.videoId }}" target="_blank">
                                <img src="{{ searchResult.snippet.thumbnails.default.url }}" width="50px">
                            </a>
                            {{ searchResult.snippet.title }}
                        </li>
                    {{/ for }}
                </ul>
            {{/ if }}
        {{/ if }}
    `;

    static props = {
        googleApiLoadedPromise: {
            get default() {
                return googleApiLoadedPromise;
            }
        },

        googleAuth: {
            async(resolve) {
                this.googleApiLoadedPromise.then(() => {
                    resolve(gapi.auth2.getAuthInstance());
                });
            }
        },

        signedIn: Boolean,

        get givenName() {
            return (
                this.googleAuth &&
                this.googleAuth.currentUser
                    .get()
                    .getBasicProfile()
                    .getGivenName()
            );
        },

        searchQuery: "",

        get searchResultsPromise() {
            if (this.searchQuery.length > 2) {
                return gapi.client.youtube.search
                    .list({
                        q: this.searchQuery,
                        part: "snippet",
                        type: "video"
                    })
                    .then(response => {
                        console.info("Search results:", response.result.items);
                        return response.result.items;
                    });
            }
        }
    };

    connected() {
        this.listenTo("googleAuth", ({ value: googleAuth }) => {
            this.signedIn = googleAuth.isSignedIn.get();
            googleAuth.isSignedIn.listen(isSignedIn => {
                this.signedIn = isSignedIn;
            });
        });
    }
}

customElements.define("playlist-editor", PlaylistEditor);

Drag videos

The problem

In this section, we will:

  1. Let a user drag around a cloned representation of the searched videos.

What you need to know

  • The jQuery++ library (which is already included on the page), supports the following drag events:

    • dragdown - the mouse cursor is pressed down
    • draginit - the drag motion is started
    • dragmove - the drag is moved
    • dragend - the drag has ended
    • dragover - the drag is over a drop point
    • dragout - the drag moved out of a drop point

    You can bind on them manually with jQuery like:

    $(element).on("draginit", function(ev, drag) {
      drag.limit($(this).parent());
      drag.horizontal();
    });
    

    Notice that drag is the 2nd argument to the event. You can listen to drag events in can-stache and pass the drag argument to a function like:

    on:draginit="startedDrag(scope.arguments[1])"
    
  • You can use addJQueryEvents() to listen to custom jQuery events (such as jQuery++’s draginit above):

    import { addJQueryEvents } from "can";
    
    addJQueryEvents(jQuery);
    
  • The drag.ghost() method copies the elements being dragged and drags that instead. The .ghost() method returns the copied elements wrapped with jQuery. Add the ghost className to style the ghost elements, like:

    drag.ghost().addClass("ghost");
    
  • To add a method to a can-stache-element, just add a function shown below:

    import { StacheElement } from "can";
    
    class PlaylistEditor extends StacheElement {
      static view = `...`;
      static props = { ... };
      startedDrag() {
        console.log("you did it!")
      }
    }
    
  • Certain browsers have default drag behaviors for certain elements like <a> and <img> that can be prevented with the draggable="false" attribute.

The solution

Update the JavaScript tab to:

import { addJQueryEvents, StacheElement } from "//unpkg.com/can@6/core.mjs";

addJQueryEvents(jQuery);

class PlaylistEditor extends StacheElement {
    static view = `
        {{# if(this.googleApiLoadedPromise.isPending) }}
            <div>Loading Google API…</div>
        {{ else }}
            {{# if(this.signedIn) }}
                Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
            {{ else }}
                <button on:click="this.googleAuth.signIn()">Sign In</button>
            {{/ if }}

            <div>
                <input value:bind="this.searchQuery" placeholder="Search for videos">
            </div>

            {{# if(this.searchResultsPromise.isPending) }}
                <div class="loading">Loading videos…</div>
            {{/ if }}

            {{# if(this.searchResultsPromise.isResolved) }}
                <ul class="source">
                    {{# for(searchResult of this.searchResultsPromise.value) }}
                        <li on:draginit="this.videoDrag(scope.arguments[1])">
                            <a draggable="false" href="https://www.youtube.com/watch?v={{ searchResult.id.videoId }}" target="_blank">
                                <img draggable="false" src="{{ searchResult.snippet.thumbnails.default.url }}" width="50px">
                            </a>
                            {{ searchResult.snippet.title }}
                        </li>
                    {{/ for }}
                </ul>
            {{/ if }}
        {{/ if }}
    `;

    static props = {
        googleApiLoadedPromise: {
            get default() {
                return googleApiLoadedPromise;
            }
        },

        googleAuth: {
            async(resolve) {
                this.googleApiLoadedPromise.then(() => {
                    resolve(gapi.auth2.getAuthInstance());
                });
            }
        },

        signedIn: Boolean,

        get givenName() {
            return (
                this.googleAuth &&
                this.googleAuth.currentUser
                    .get()
                    .getBasicProfile()
                    .getGivenName()
            );
        },

        searchQuery: "",

        get searchResultsPromise() {
            if (this.searchQuery.length > 2) {
                return gapi.client.youtube.search
                    .list({
                        q: this.searchQuery,
                        part: "snippet",
                        type: "video"
                    })
                    .then(response => {
                        console.info("Search results:", response.result.items);
                        return response.result.items;
                    });
            }
        }
    };

    connected() {
        this.listenTo("googleAuth", ({ value: googleAuth }) => {
            this.signedIn = googleAuth.isSignedIn.get();
            googleAuth.isSignedIn.listen(isSignedIn => {
                this.signedIn = isSignedIn;
            });
        });
    }

    videoDrag(drag) {
        drag.ghost().addClass("ghost");
    }
}

customElements.define("playlist-editor", PlaylistEditor);

Drop videos

The problem

In this section, we will:

  1. Allow a user to drop videos on a playlist element.
  2. When the user drags a video over the playlist element, a placeholder of the video will appear in the first position of the playlist.
  3. If the video is dragged out of the playlist element, the placeholder will be removed.
  4. If the video is dropped on the playlist element, it will be added to the playlist’s list of videos.
  5. Prepare for inserting the placeholder or video in any position in the list.

What you need to know

  • The PlaylistEditor element should maintain a list of playlist videos (playlistVideos) and the placeholder video (dropPlaceholderData) separately. It can combine these two values into a single value (videosWithDropPlaceholder) of the videos to display to the user. On a high-level, this might look like:

    import { StacheElement, type, ObservableArray } from "can";
    
    class PlaylistEditor extends StacheElement {
      static view = `...`;
      static props = {
        // ...
        // { video: video, index: 0 }
        dropPlaceholderData: type.Any,
    
        // [video1, video2, ...]
        playlistVideos: {
          get default() {
            return new ObservableArray();
          }
        },
    
        get videosWithDropPlaceholder() {
          const copyOfPlaylistVideos = this.placeListVideos.map( /* ... */ );
          // insert this.dropPlaceholderData into copyOfPlaylistVideos
          return copyOfPlaylistVideos;
        }
      };
    }
    
  • The methods that add a placeholder (addDropPlaceholder) and add video to the playlist (addVideo) should take an index like:

    addDropPlaceholder(index, video) { /* ... */ }
    addVideo(index, video) { /* ... */ }
    

    These functions will be called with 0 as the index for this section.

  • jQuery++ supports the following drop events:

    • dropinit - the drag motion is started, drop positions are calculated
    • dropover - a drag moves over a drop element, called once as the drop is dragged over the element
    • dropout - a drag moves out of the drop element
    • dropmove - a drag is moved over a drop element, called repeatedly as the element is moved
    • dropon - a drag is released over a drop element
    • dropend - the drag motion has completed

    You can bind on them manually with jQuery like:

    $(element).on("dropon", (ev, drop, drag) => { /* ... */ });
    

    Notice that drop is now the 2nd argument to the event. You can listen to drop events in can-stache, and pass the drag argument to a function, like:

    on:dropon="addVideo(scope.arguments[2])"
    
  • You will need to associate the drag objects with the video being dragged so you know which video is being dropped when a drop happens. The following utilities help create that association:

    • The drag.element is the jQuery-wrapped element that the user initiated the drag motion upon.

    • CanJS’s {{ domData("DATANAME") }} helper lets you associate custom data with an element. The following saves the current context of the <li> as "dragData" on the <li>:

      <li on:draginit="this.videoDrag(scope.arguments[1])"
          {{domData("dragData")}}></li>
      
    • domData.get() can access this data like:

      import { domData } from "can";
      
      domData.get(drag.element[0], "dragData");
      

The solution

Update the JavaScript tab to:

import {
    addJQueryEvents,
    ObservableArray,
    StacheElement,
    type
} from "//unpkg.com/can@6/core.mjs";

addJQueryEvents(jQuery);

class PlaylistEditor extends StacheElement {
    static view = `
        {{# if(this.googleApiLoadedPromise.isPending) }}
            <div>Loading Google API…</div>
        {{ else }}
            {{# if(this.signedIn) }}
                Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
            {{ else }}
                <button on:click="this.googleAuth.signIn()">Sign In</button>
            {{/ if }}

            <div>
                <input value:bind="this.searchQuery" placeholder="Search for videos">
            </div>

            {{# if(this.searchResultsPromise.isPending) }}
                <div class="loading">Loading videos…</div>
            {{/ if }}

            {{# if(this.searchResultsPromise.isResolved) }}
                <ul class="source">
                    {{# for(searchResult of this.searchResultsPromise.value) }}
                        <li on:draginit="this.videoDrag(scope.arguments[1])" 
                                {{ domData("dragData", searchResult) }}>
                            <a draggable="false" href="https://www.youtube.com/watch?v={{ searchResult.id.videoId }}" target="_blank">
                                <img draggable="false" src="{{ searchResult.snippet.thumbnails.default.url }}" width="50px">
                            </a>
                            {{ searchResult.snippet.title }}
                        </li>
                    {{/ for }}
                </ul>
            {{/ if }}

            {{# if(this.searchResultsPromise.value.length) }}
                <div class="new-playlist">
                    <ul
                        on:dropover="this.addDropPlaceholder(0,this.getDragData(scope.arguments[2]))"
                        on:dropout="this.clearDropPlaceholder()"
                        on:dropon="this.addVideo(0,this.getDragData(scope.arguments[2]))"
                    >
                        {{# for(videoWithDropPlaceholder of this.videosWithDropPlaceholder) }}
                            <li class="{{# if(videoWithDropPlaceholder.isPlaceholder) }}placeholder{{/ if }}">
                                <a href="https://www.youtube.com/watch?v={{ videoWithDropPlaceholder.video.id.videoId }}" target="_blank">
                                    <img src="{{ videoWithDropPlaceholder.video.snippet.thumbnails.default.url }}" width="50px">
                                </a>

                                {{ videoWithDropPlaceholder.video.snippet.title }}
                            </li>
                        {{ else }}
                            <div class="content">Drag video here</div>
                        {{/ for }}
                    </ul>
                </div>
            {{/ if }}
        {{/ if }}
    `;

    static props = {
        googleApiLoadedPromise: {
            get default() {
                return googleApiLoadedPromise;
            }
        },

        googleAuth: {
            async(resolve) {
                this.googleApiLoadedPromise.then(() => {
                    resolve(gapi.auth2.getAuthInstance());
                });
            }
        },

        signedIn: Boolean,

        get givenName() {
            return (
                this.googleAuth &&
                this.googleAuth.currentUser
                    .get()
                    .getBasicProfile()
                    .getGivenName()
            );
        },

        searchQuery: "",

        dropPlaceholderData: type.Any,

        playlistVideos: {
            get default() {
                return new ObservableArray();
            }
        },

        get searchResultsPromise() {
            if (this.searchQuery.length > 2) {
                return gapi.client.youtube.search
                    .list({
                        q: this.searchQuery,
                        part: "snippet",
                        type: "video"
                    })
                    .then(response => {
                        console.info("Search results:", response.result.items);
                        return response.result.items;
                    });
            }
        },

        get videosWithDropPlaceholder() {
            const copy = this.playlistVideos.map(video => {
                return {
                    video: video,
                    isPlaceholder: false
                };
            });
            if (this.dropPlaceholderData) {
                copy.splice(this.dropPlaceholderData.index, 0, {
                    video: this.dropPlaceholderData.video,
                    isPlaceholder: true
                });
            }
            return copy;
        }
    };

    connected() {
        this.listenTo("googleAuth", ({ value: googleAuth }) => {
            this.signedIn = googleAuth.isSignedIn.get();
            googleAuth.isSignedIn.listen(isSignedIn => {
                this.signedIn = isSignedIn;
            });
        });
    }

    videoDrag(drag) {
        drag.ghost().addClass("ghost");
    }

    getDragData(drag) {
        return can.domData.get(drag.element[0], "dragData");
    }

    addDropPlaceholder(index, video) {
        this.dropPlaceholderData = {
            index: index,
            video: video
        };
    }

    clearDropPlaceholder() {
        this.dropPlaceholderData = null;
    }

    addVideo(index, video) {
        this.dropPlaceholderData = null;
        if (index >= this.playlistVideos.length) {
            this.playlistVideos.push(video);
        } else {
            this.playlistVideos.splice(index, 0, video);
        }
    }
}

customElements.define("playlist-editor", PlaylistEditor);

Drop videos in order

The problem

In this section, we will:

  1. Allow a user to drop videos in order they prefer.

What you need to know

  • [can-stache-elements StacheElement]s are best left knowing very little about the DOM. This makes them more easily unit-testable. To make this interaction, we need to know where the mouse is in relation to the playlist’s videos. This requires a lot of DOM interaction and is best done outside the element.

    Specifically, we’d like to translate the dropmove and dropon events into other events that let people know where the dropmove and dropon events are happening in relationship to the drop target’s child elements.

    Our goal is to:

    • Translate dropmove into sortableplaceholderat events that dispatch events with the index where a placeholder should be inserted and the dragData of what is being dragged.

    • Translate dropon into sortableinsertat events that dispatch events with the index where the dragged item should be inserted and the dragData of what is being dragged.

  • Control is useful for listening to events on an element in a memory-safe way. Use extend to define a Control type, as follows:

    import { Control } from "can";
    
    const Sortable = Control.extend({
      // Event handlers and methods
    });
    

    To listen to events (like dragmove) on a control, use an event handler with {element} EVENTNAME, as follows:

    import { Control } from "can";
    
    const Sortable = Control.extend({
      "{element} dropmove": function(el, ev, drop, drag) {
        // do stuff on dropmove like call method:
        this.method();
      },
      method() {
        // do something
      }
    });
    

    Use new Control(element) to create a control on an element. The following would setup the dropmove binding on el:

    new Sortable(el);
    
  • viewCallbacks.attr() can listen to when a custom attribute is found in a can-stache template like:

    import { viewCallbacks } from "can";
    
    viewCallbacks.attr("sortable", function(el, attrData) {});
    

    This can be useful to create controls on an element with that attribute. For example, if a user has:

    <ul sortable>
      <!-- ... -->
    </ul>
    

    The following will create the Sortable control on that <ul>:

    import { viewCallbacks } from "can";
    
    viewCallbacks.attr("sortable", function(el) {
      new Sortable(el);
    });
    
  • Use domEvents.dispatch() to fire custom events:

    import { domEvents } from "can";
    
    domEvents.dispatch(element, {
      type: "sortableinsertat",
      index: 0,
      dragData: dragData
    });
    
  • Access the event object in a on:event with scope.event, like:

    on:sortableinsertat="addVideo(scope.event.index, scope.event.dragData)"
    
  • Mouse events like click and dropmove and dropon have a pageY property that tells how many pixels down the page a user’s mouse is.

  • jQuery.offset returns an element’s position on the page.

  • jQuery.height returns an element’s height.

  • If the mouse position is below an element’s center, the placeholder should be inserted after the element. If the mouse position is above an element’s center, it should be inserted before the element.

The solution

Update the JavaScript tab to:

import {
    addJQueryEvents,
    Control,
    StacheElement,
    domData,
    domEvents,
    ObservableArray,
    type,
    viewCallbacks
} from "//unpkg.com/can@6/core.mjs";

addJQueryEvents(jQuery);

const Sortable = Control.extend({
    "{element} dropmove": function(el, ev, drop, drag) {
        this.fireEventForDropPosition(ev, drop, drag, "sortableplaceholderat");
    },
    "{element} dropon": function(el, ev, drop, drag) {
        this.fireEventForDropPosition(ev, drop, drag, "sortableinsertat");
    },
    fireEventForDropPosition: function(ev, drop, drag, eventName) {
        const dragData = domData.get(drag.element[0], "dragData");

        const sortables = $(this.element).children();

        for (var i = 0; i < sortables.length; i++) {
            //check if cursor is past 1/2 way
            const sortable = $(sortables[i]);
            if (
                ev.pageY < Math.floor(sortable.offset().top + sortable.height() / 2)
            ) {
                // index at which it needs to be inserted before
                domEvents.dispatch(this.element, {
                    type: eventName,
                    index: i,
                    dragData: dragData
                });
                return;
            }
        }
        if (!sortables.length) {
            domEvents.dispatch(this.element, {
                type: eventName,
                index: 0,
                dragData: dragData
            });
        } else {
            domEvents.dispatch(this.element, {
                type: eventName,
                index: i,
                dragData: dragData
            });
        }
    }
});

viewCallbacks.attr("sortable", function(el) {
    new Sortable(el);
});

class PlaylistEditor extends StacheElement {
    static view = `
        {{# if(this.googleApiLoadedPromise.isPending) }}
            <div>Loading Google API…</div>
        {{ else }}
            {{# if(this.signedIn) }}
                Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
            {{ else }}
                <button on:click="this.googleAuth.signIn()">Sign In</button>
            {{/ if }}

            <div>
                <input value:bind="this.searchQuery" placeholder="Search for videos">
            </div>

            {{# if(this.searchResultsPromise.isPending) }}
                <div class="loading">Loading videos…</div>
            {{/ if }}

            {{# if(this.searchResultsPromise.isResolved) }}
                <ul class="source">
                    {{# for(searchResult of this.searchResultsPromise.value) }}
                        <li on:draginit="this.videoDrag(scope.arguments[1])" 
                                {{ domData("dragData", searchResult) }}>
                            <a draggable="false" href="https://www.youtube.com/watch?v={{ searchResult.id.videoId }}" target="_blank">
                                <img draggable="false" src="{{ searchResult.snippet.thumbnails.default.url }}" width="50px">
                            </a>
                            {{ searchResult.snippet.title }}
                        </li>
                    {{/ for }}
                </ul>
            {{/ if }}

            {{# if(this.searchResultsPromise.value.length) }}
                <div class="new-playlist">
                    <ul
                        sortable
                        on:sortableplaceholderat="this.addDropPlaceholder(scope.event.index, scope.event.dragData)"
                        on:sortableinsertat="this.addVideo(scope.event.index, scope.event.dragData)"
                        on:dropout="this.clearDropPlaceholder()"
                    >
                        {{# for(videoWithDropPlaceholder of this.videosWithDropPlaceholder) }}
                            <li class="{{# if(videoWithDropPlaceholder.isPlaceholder) }}placeholder{{/ if }}">
                                <a href="https://www.youtube.com/watch?v={{ videoWithDropPlaceholder.video.id.videoId }}" target="_blank">
                                    <img src="{{ videoWithDropPlaceholder.video.snippet.thumbnails.default.url }}" width="50px">
                                </a>

                                {{ videoWithDropPlaceholder.video.snippet.title }}
                            </li>
                        {{ else }}
                            <div class="content">Drag video here</div>
                        {{/ for }}
                    </ul>
                </div>
            {{/ if }}
        {{/ if }}
    `;

    static props = {
        googleApiLoadedPromise: {
            get default() {
                return googleApiLoadedPromise;
            }
        },

        googleAuth: {
            async(resolve) {
                this.googleApiLoadedPromise.then(() => {
                    resolve(gapi.auth2.getAuthInstance());
                });
            }
        },

        signedIn: Boolean,

        get givenName() {
            return (
                this.googleAuth &&
                this.googleAuth.currentUser
                    .get()
                    .getBasicProfile()
                    .getGivenName()
            );
        },

        searchQuery: "",

        dropPlaceholderData: type.Any,

        playlistVideos: {
            get default() {
                return new ObservableArray();
            }
        },

        get searchResultsPromise() {
            if (this.searchQuery.length > 2) {
                return gapi.client.youtube.search
                    .list({
                        q: this.searchQuery,
                        part: "snippet",
                        type: "video"
                    })
                    .then(response => {
                        console.info("Search results:", response.result.items);
                        return response.result.items;
                    });
            }
        },

        get videosWithDropPlaceholder() {
            const copy = this.playlistVideos.map(video => {
                return {
                    video: video,
                    isPlaceholder: false
                };
            });
            if (this.dropPlaceholderData) {
                copy.splice(this.dropPlaceholderData.index, 0, {
                    video: this.dropPlaceholderData.video,
                    isPlaceholder: true
                });
            }
            return copy;
        }
    };

    connected() {
        this.listenTo("googleAuth", ({ value: googleAuth }) => {
            this.signedIn = googleAuth.isSignedIn.get();
            googleAuth.isSignedIn.listen(isSignedIn => {
                this.signedIn = isSignedIn;
            });
        });
    }

    videoDrag(drag) {
        drag.ghost().addClass("ghost");
    }

    getDragData(drag) {
        return can.domData.get(drag.element[0], "dragData");
    }

    addDropPlaceholder(index, video) {
        this.dropPlaceholderData = {
            index: index,
            video: video
        };
    }

    clearDropPlaceholder() {
        this.dropPlaceholderData = null;
    }

    addVideo(index, video) {
        this.dropPlaceholderData = null;
        if (index >= this.playlistVideos.length) {
            this.playlistVideos.push(video);
        } else {
            this.playlistVideos.splice(index, 0, video);
        }
    }
}

customElements.define("playlist-editor", PlaylistEditor);

Revert videos not dropped on playlist

The problem

In this section, we will:

  1. Revert videos not dropped on the playlist. If a user drags a video, but does not drop it on the playlist, show an animation returning the video to its original place.

What you need to know

  • If you call drag.revert(), the drag element will animate back to its original position.

The solution

Update the JavaScript tab to:

import {
    addJQueryEvents,
    Control,
    StacheElement,
    domData,
    domEvents,
    ObservableArray,
    type,
    viewCallbacks
} from "//unpkg.com/can@6/core.mjs";

addJQueryEvents(jQuery);

const Sortable = Control.extend({
    "{element} dropinit": function() {
        this.droppedOn = false;
    },
    "{element} dropmove": function(el, ev, drop, drag) {
        this.fireEventForDropPosition(ev, drop, drag, "sortableplaceholderat");
    },
    "{element} dropon": function(el, ev, drop, drag) {
        this.droppedOn = true;
        this.fireEventForDropPosition(ev, drop, drag, "sortableinsertat");
    },
    "{element} dropend": function(el, ev, drop, drag) {
        if (!this.droppedOn) {
            drag.revert();
        }
    },
    fireEventForDropPosition: function(ev, drop, drag, eventName) {
        const dragData = domData.get(drag.element[0], "dragData");

        const sortables = $(this.element).children();

        for (var i = 0; i < sortables.length; i++) {
            //check if cursor is past 1/2 way
            const sortable = $(sortables[i]);
            if (
                ev.pageY < Math.floor(sortable.offset().top + sortable.height() / 2)
            ) {
                // index at which it needs to be inserted before
                domEvents.dispatch(this.element, {
                    type: eventName,
                    index: i,
                    dragData: dragData
                });
                return;
            }
        }
        if (!sortables.length) {
            domEvents.dispatch(this.element, {
                type: eventName,
                index: 0,
                dragData: dragData
            });
        } else {
            domEvents.dispatch(this.element, {
                type: eventName,
                index: i,
                dragData: dragData
            });
        }
    }
});

viewCallbacks.attr("sortable", function(el) {
    new Sortable(el);
});

class PlaylistEditor extends StacheElement {
    static view = `
        {{# if(this.googleApiLoadedPromise.isPending) }}
            <div>Loading Google API…</div>
        {{ else }}
            {{# if(this.signedIn) }}
                Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
            {{ else }}
                <button on:click="this.googleAuth.signIn()">Sign In</button>
            {{/ if }}

            <div>
                <input value:bind="this.searchQuery" placeholder="Search for videos">
            </div>

            {{# if(this.searchResultsPromise.isPending) }}
                <div class="loading">Loading videos…</div>
            {{/ if }}

            {{# if(this.searchResultsPromise.isResolved) }}
                <ul class="source">
                    {{# for(searchResult of this.searchResultsPromise.value) }}
                        <li on:draginit="this.videoDrag(scope.arguments[1])" 
                                {{ domData("dragData", searchResult) }}>
                            <a draggable="false" href="https://www.youtube.com/watch?v={{ searchResult.id.videoId }}" target="_blank">
                                <img draggable="false" src="{{ searchResult.snippet.thumbnails.default.url }}" width="50px">
                            </a>
                            {{ searchResult.snippet.title }}
                        </li>
                    {{/ for }}
                </ul>
            {{/ if }}

            {{# if(this.searchResultsPromise.value.length) }}
                <div class="new-playlist">
                    <ul
                        sortable
                        on:sortableplaceholderat="this.addDropPlaceholder(scope.event.index, scope.event.dragData)"
                        on:sortableinsertat="this.addVideo(scope.event.index, scope.event.dragData)"
                        on:dropout="this.clearDropPlaceholder()"
                    >
                        {{# for(videoWithDropPlaceholder of this.videosWithDropPlaceholder) }}
                            <li class="{{# if(videoWithDropPlaceholder.isPlaceholder) }}placeholder{{/ if }}">
                                <a href="https://www.youtube.com/watch?v={{ videoWithDropPlaceholder.video.id.videoId }}" target="_blank">
                                    <img src="{{ videoWithDropPlaceholder.video.snippet.thumbnails.default.url }}" width="50px">
                                </a>

                                {{ videoWithDropPlaceholder.video.snippet.title }}
                            </li>
                        {{ else }}
                            <div class="content">Drag video here</div>
                        {{/ for }}
                    </ul>
                </div>
            {{/ if }}
        {{/ if }}
    `;

    static props = {
        googleApiLoadedPromise: {
            get default() {
                return googleApiLoadedPromise;
            }
        },

        googleAuth: {
            async(resolve) {
                this.googleApiLoadedPromise.then(() => {
                    resolve(gapi.auth2.getAuthInstance());
                });
            }
        },

        signedIn: Boolean,

        get givenName() {
            return (
                this.googleAuth &&
                this.googleAuth.currentUser
                    .get()
                    .getBasicProfile()
                    .getGivenName()
            );
        },

        searchQuery: "",

        dropPlaceholderData: type.Any,

        playlistVideos: {
            get default() {
                return new ObservableArray();
            }
        },

        get searchResultsPromise() {
            if (this.searchQuery.length > 2) {
                return gapi.client.youtube.search
                    .list({
                        q: this.searchQuery,
                        part: "snippet",
                        type: "video"
                    })
                    .then(response => {
                        console.info("Search results:", response.result.items);
                        return response.result.items;
                    });
            }
        },

        get videosWithDropPlaceholder() {
            const copy = this.playlistVideos.map(video => {
                return {
                    video: video,
                    isPlaceholder: false
                };
            });
            if (this.dropPlaceholderData) {
                copy.splice(this.dropPlaceholderData.index, 0, {
                    video: this.dropPlaceholderData.video,
                    isPlaceholder: true
                });
            }
            return copy;
        }
    };

    connected() {
        this.listenTo("googleAuth", ({ value: googleAuth }) => {
            this.signedIn = googleAuth.isSignedIn.get();
            googleAuth.isSignedIn.listen(isSignedIn => {
                this.signedIn = isSignedIn;
            });
        });
    }

    videoDrag(drag) {
        drag.ghost().addClass("ghost");
    }

    getDragData(drag) {
        return can.domData.get(drag.element[0], "dragData");
    }

    addDropPlaceholder(index, video) {
        this.dropPlaceholderData = {
            index: index,
            video: video
        };
    }

    clearDropPlaceholder() {
        this.dropPlaceholderData = null;
    }

    addVideo(index, video) {
        this.dropPlaceholderData = null;
        if (index >= this.playlistVideos.length) {
            this.playlistVideos.push(video);
        } else {
            this.playlistVideos.splice(index, 0, video);
        }
    }
}

customElements.define("playlist-editor", PlaylistEditor);

Create a playlist

The problem

In this section, we will:

  1. Add a Create Playlist button that prompts the user for the playlist name.
  2. After the user enters the name, the playlist is saved.
  3. Disable the button while the playlist is being created.
  4. Empty the playlist after it is created.

What you need to know

  • Use https://developer.mozilla.org/en-US/docs/Web/API/Window/prompt to prompt a user for a simple string value.

  • YouTube only allows you to create a playlist and add items to it.

    To create a playlist:

    let lastPromise = gapi.client.youtube.playlists.insert({
      part: "snippet,status",
      resource: {
        snippet: {
          title: PLAYLIST_NAME,
          description: "A private playlist created with the YouTube API and CanJS"
        },
        status: {
          privacyStatus: "private"
        }
      }
    }).then(function(response) {
      response //->{} response.result.id
      // result: {
      //   id: "lk2asf8o"
      // }
    });
    

    To insert something onto the end of it:

    gapi.client.youtube.playlistItems.insert({
      part: "snippet",
      resource: {
        snippet: {
          playlistId: playlistId,
          resourceId: video.id
        }
      }
    }).then();
    
  • These requests must run in order. You can make one request run after another, like:

    lastPromise = makeRequest(1);
    
    lastPromise = lastPromise.then(function() {
      return makeRequest(2);    
    });
    
    lastPromise = lastPromise.then(function() {
      return makeRequest(3);    
    });
    

    When a callback to .then returns a promise, .then returns a promise that resolves after the inner promise has been resolved.

  • Use {disabled:from="boolean"} to make an input disabled, like:

    <button disabled:from="createPlaylistPromise.isPending()">
    
  • When the promise has finished, set the playlistVideos property back to an empty list. This can be done by listening to createPlaylistPromise:

    this.listenTo("createPlaylistPromise", function({ value: promise }) { /* ... */ })
    

The solution

Update the JavaScript tab to:

import {
  addJQueryEvents,
  Control,
  domData,
  domEvents,
  ObservableArray,
  StacheElement,
  type,
  viewCallbacks
} from "//unpkg.com/can@pre/core.mjs";

addJQueryEvents(jQuery);

const Sortable = Control.extend({
  "{element} dropinit": function() {
    this.droppedOn = false;
  },
  "{element} dropmove": function(el, ev, drop, drag) {
    this.fireEventForDropPosition(ev, drop, drag, "sortableplaceholderat");
  },
  "{element} dropon": function(el, ev, drop, drag) {
    this.droppedOn = true;
    this.fireEventForDropPosition(ev, drop, drag, "sortableinsertat");
  },
  "{element} dropend": function(el, ev, drop, drag) {
    if (!this.droppedOn) {
      drag.revert();
    }
  },
  fireEventForDropPosition(ev, drop, drag, eventName) {
    const dragData = domData.get(drag.element[0], "dragData");
    const sortables = $(this.element).children();

    for (var i = 0; i < sortables.length; i++) {
      //check if cursor is past 1/2 way
      const sortable = $(sortables[i]);
      if (
        ev.pageY < Math.floor(sortable.offset().top + sortable.height() / 2)
      ) {
        // index at which it needs to be inserted before
        domEvents.dispatch(this.element, {
          type: eventName,
          index: i,
          dragData: dragData
        });
        return;
      }
    }
    if (!sortables.length) {
      domEvents.dispatch(this.element, {
        type: eventName,
        index: 0,
        dragData: dragData
      });
    } else {
      domEvents.dispatch(this.element, {
        type: eventName,
        index: i,
        dragData: dragData
      });
    }
  }
});

viewCallbacks.attr("sortable", el => {
  new Sortable(el);
});

class PlaylistEditor extends StacheElement {
  static view = `
    {{# if(this.googleApiLoadedPromise.isPending) }}
        <div>Loading Google API…</div>
    {{ else }}
      {{# if(this.signedIn) }}
        Welcome {{ this.givenName }}! <button on:click="this.googleAuth.signOut()">Sign Out</button>
      {{ else }}
        <button on:click="this.googleAuth.signIn()">Sign In</button>
      {{/ if }}

      <div>
        <input value:bind="this.searchQuery" placeholder="Search for videos">
      </div>

      {{# if(this.searchResultsPromise.isPending) }}
        <div class="loading">Loading videos…</div>
      {{/ if }}

      {{# if(this.searchResultsPromise.isResolved) }}
        <ul class="source">
          {{# for(searchResult of this.searchResultsPromise.value) }}
            <li 
              on:draginit="this.videoDrag(scope.arguments[1])"
              {{ domData("dragData", searchResult) }}
            >
              <a draggable="false" href="https://www.youtube.com/watch?v={{ searchResult.id.videoId }}" target='_blank'>
                <img draggable="false" src="{{ searchResult.snippet.thumbnails.default.url }}" width="50px">
              </a>
              {{ searchResult.snippet.title }}
            </li>
          {{/ for }}
        </ul>

        {{# if(this.searchResultsPromise.value.length) }}
          <div class="new-playlist">
            <ul
              sortable
              on:sortableplaceholderat="this.addDropPlaceholder(scope.event.index, scope.event.dragData)"
              on:sortableinsertat="this.addVideo(scope.event.index, scope.event.dragData)"
              on:dropout="this.clearDropPlaceholder()"
            >
              {{# for(videoWithDropPlaceholder of this.videosWithDropPlaceholder) }}
                <li class="{{# if(videoWithDropPlaceholder.isPlaceholder) }}placeholder{{/ if }}">
                  <a href="https://www.youtube.com/watch?v={{ videoWithDropPlaceholder.video.id.videoId }}" target='_blank'>
                    <img src="{{ videoWithDropPlaceholder.video.snippet.thumbnails.default.url }}" width="50px">
                  </a>
                  {{ videoWithDropPlaceholder.video.snippet.title }}
                </li>
              {{else}}
                <div class="content">Drag video here</div>
              {{/ for }}
            </ul>
            {{# if(this.playlistVideos.length) }}
              <button 
                on:click="this.createPlaylist()"
                disabled:from="this.createPlaylistPromise.isPending()"
              >
                Create Playlist
              </button>
            {{/ if }}
          </div>
        {{/ if }}

      {{/ if }}
    {{/ if }}
  `;

  static props = {
    signedIn: Boolean,

    googleApiLoadedPromise: {
      get default() {
        return googleApiLoadedPromise;
      }
    },

    googleAuth: {
      async(resolve) {
        this.googleApiLoadedPromise.then(() => {
          resolve(gapi.auth2.getAuthInstance());
        });
      }
    },

    searchQuery: "",

    dropPlaceholderData: type.Any,

    playlistVideos: {
      get default() {
        return new ObservableArray();
      }
    },

    createPlaylistPromise: type.Any,

    get givenName() {
      return (
        this.googleAuth &&
        this.googleAuth.currentUser
          .get()
          .getBasicProfile()
          .getGivenName()
      );
    },

    get searchResultsPromise() {
      if (this.searchQuery.length > 2) {
        return gapi.client.youtube.search
          .list({
            q: this.searchQuery,
            part: "snippet",
            type: "video"
          })
          .then(response => {
            console.info("Search results:", response.result.items);
            return response.result.items;
          });
      }
    },

    get videosWithDropPlaceholder() {
      const copy = this.playlistVideos.map(video => {
        return {
          video: video,
          isPlaceholder: false
        };
      });
      if (this.dropPlaceholderData) {
        copy.splice(this.dropPlaceholderData.index, 0, {
          video: this.dropPlaceholderData.video,
          isPlaceholder: true
        });
      }
      return copy;
    }
  };

  connected() {
    this.listenTo("googleAuth", ({ value: googleAuth }) => {
      this.signedIn = googleAuth.isSignedIn.get();
      googleAuth.isSignedIn.listen(isSignedIn => {
        this.signedIn = isSignedIn;
      });
    });

    this.listenTo("createPlaylistPromise", ({ value: promise }) => {
      if (promise) {
        promise.then(() => {
          this.playlistVideos = [];
          this.createPlaylistPromise = null;
        });
      }
    });
  }

  videoDrag(drag) {
    drag.ghost().addClass("ghost");
  }

  getDragData(drag) {
    return domData.get(drag.element[0], "dragData");
  }

  addDropPlaceholder(index, video) {
    this.dropPlaceholderData = {
      index: index,
      video: video
    };
  }

  clearDropPlaceholder() {
    this.dropPlaceholderData = null;
  }

  addVideo(index, video) {
    this.dropPlaceholderData = null;
    if (index >= this.playlistVideos.length) {
      this.playlistVideos.push(video);
    } else {
      this.playlistVideos.splice(index, 0, video);
    }
  }

  createPlaylist() {
    const playlistName = prompt("What would you like to name your playlist?");
    if (!playlistName) {
      return;
    }

    let playlistId;
    let lastPromise = gapi.client.youtube.playlists
      .insert({
        part: "snippet,status",
        resource: {
          snippet: {
            title: playlistName,
            description:
              "A private playlist created with the YouTube API and CanJS"
          },
          status: {
            privacyStatus: "private"
          }
        }
      })
      .then(response => {
        playlistId = response.result.id;
      });

    const playlistVideos = this.playlistVideos.slice();
    playlistVideos.forEach(video => {
      lastPromise = lastPromise.then(() => {
        return gapi.client.youtube.playlistItems
          .insert({
            part: "snippet",
            resource: {
              snippet: {
                playlistId: playlistId,
                resourceId: video.id
              }
            }
          });
      });
    });

    this.createPlaylistPromise = lastPromise;
  }
}

customElements.define("playlist-editor", PlaylistEditor);

Result

Congrats! You now have your very own YouTube Playlist Editor.

When finished, you should see something like the following CodePen:

See the Pen Playlist Editor (Advanced) [Finished] by Bitovi (@bitovi) on CodePen.

CanJS is part of DoneJS. Created and maintained by the core DoneJS team and Bitovi. Currently 6.0.1.

On this page

Get help

  • Chat with us
  • File an issue
  • Ask questions
  • Read latest news