Criar projeto frontend Ionic com Vue.js para consumir a API REST do Spotify, com Vue Router e TypeScript. Objetivo é usuário informar ID playlist, e mostrará informações da mesma. Projeto baseado na API REST oficial do Spotify.
<template>
<ion-page>
<ion-header>
<ion-toolbar color="primary">
<ion-title class="ion-text-center ">Spotify Playlist</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-card v-if="!hasToken" class="ion-text-center">
<ion-card-header>
<ion-card-title>Conectar ao Spotify</ion-card-title>
<ion-card-subtitle>Acesse suas playlists públicas</ion-card-subtitle>
</ion-card-header>
<ion-button expand="block" color="success" @click="loginWithSpotify">
Login com Spotify
</ion-button>
</ion-card>
<ion-card v-if="hasToken">
<ion-card-header>
<ion-card-title>Buscar Playlist</ion-card-title>
</ion-card-header>
<ion-card-content>
<ion-item>
<ion-label position="stacked">URL da Playlist</ion-label>
<ion-input v-model="url" placeholder="https://open.spotify.com/playlist/..."
clear-input></ion-input>
</ion-item>
<ion-button expand="block" class="ion-margin-top" :disabled="!url" @click="submit">
Ver Playlist
</ion-button>
</ion-card-content>
</ion-card>
<div v-if="loading" class="ion-text-center ion-margin-top">
<ion-spinner name="crescent" />
<p>Carregando playlist...</p>
</div>
<div v-if="playlist">
<ion-grid>
<ion-row class="ion-justify-content-center">
<ion-col size="12" size-md="6" size-lg="4">
<ion-card class="playlist-cover-card ion-text-center ion-padding">
<ion-img :src="playlist.images[0]?.url" alt="Capa da Playlist" />
<h1>{{ playlist.name }}</h1>
<p>{{ playlist.description || 'Sem descrição disponível' }}</p>
</ion-card>
</ion-col>
</ion-row>
<ion-row>
<ion-col v-for="item in playlist.tracks.items" :key="item.track.id" size="12" size-sm="6"
size-md="4" size-lg="3" class="ion-padding">
<ion-card class="track-card ion-text-light">
<ion-img :src="item.track.album.images[1]?.url || item.track.album.images[0]?.url"
alt="Capa do Álbum" />
<ion-card-header>
<ion-card-title>{{ item.track.name }}</ion-card-title>
<ion-card-subtitle>{{item.track.artists.map(a => a.name).join(', ')
}}</ion-card-subtitle>
</ion-card-header>
<ion-card-content class="ion-text-center">
<ion-button expand="block" color="success" shape="round" fill="solid"
:href="item.track.external_urls.spotify" target="_blank"
rel="noopener noreferrer">
Ouvir
<ion-icon name="play-outline" slot="end" />
</ion-button>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</div>
</ion-content>
</ion-page>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import axios from 'axios';
import {
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonContent,
IonInput,
IonItem,
IonButton,
IonCard,
IonCardHeader,
IonCardTitle,
IonCardSubtitle,
IonCardContent,
IonGrid,
IonRow,
IonCol,
IonSpinner,
IonIcon,
IonImg
} from '@ionic/vue';
import { redirectToSpotifyLogin, getAccessToken } from '../services/auth';
import { playOutline } from 'ionicons/icons';
import { addIcons } from 'ionicons';
addIcons({ 'play-outline': playOutline });
const url = ref('');
const playlist = ref<any | null>(null);
const loading = ref(false);
const hasToken = ref(!!getAccessToken());
function loginWithSpotify() {
redirectToSpotifyLogin();
}
async function submit() {
const token = getAccessToken();
if (!token) {
alert('Você precisa estar logado no Spotify.');
return;
}
const match = url.value.match(/playlist\/([a-zA-Z0-9]+)/);
if (!match) {
alert('URL inválida.');
return;
}
const playlistId = match[1];
loading.value = true;
playlist.value = null;
try {
const { data } = await axios.get(`https://api.spotify.com/v1/playlists/${playlistId}`, {
headers: { Authorization: `Bearer ${token}` }
});
playlist.value = data;
} catch {
alert('Erro ao buscar playlist. Verifique se a playlist é pública ou seu token está válido.');
} finally {
loading.value = false;
}
}
</script>
<style scoped>
ion-card.track-card {
border-radius: 16px;
overflow: hidden;
background-color: #222;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
transition: transform 0.2s ease, box-shadow 0.2s ease;
margin: 0 12px 20px 12px;
}
ion-card.track-card:hover {
transform: translateY(-6px);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.9);
}
ion-card.track-card ion-img {
height: 200px;
object-fit: cover;
}
ion-card.track-card ion-card-title {
font-size: 1.2rem;
font-weight: 700;
color: #fff;
}
ion-card.track-card ion-card-subtitle {
font-size: 1rem;
color: #bbb;
}
ion-card.playlist-cover-card {
border-radius: 20px;
background-color: #1e1e1e;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.8);
margin: 24px auto 32px auto;
max-width: 100%;
color: #eee;
transition: box-shadow 0.3s ease;
}
ion-card.playlist-cover-card:hover {
box-shadow: 0 12px 40px rgba(0, 0, 0, 1);
}
ion-card.playlist-cover-card ion-img {
max-width: 220px;
height: auto;
border-radius: 24px;
object-fit: cover;
margin: 0 auto 20px auto;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.7);
}
ion-card.playlist-cover-card h1 {
font-size: 2rem;
font-weight: 800;
margin-bottom: 12px;
color: #fff;
}
ion-card.playlist-cover-card p {
font-size: 1.1rem;
color: #ccc;
padding: 0 12px;
line-height: 1.4;
user-select: text;
}
</style>
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import CallbackView from '../views/CallbackView.vue';
const routes = [
{
path: '/',
name: 'Home',
component: HomeView
},
{
path: '/callback',
name: 'Callback',
component: CallbackView
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
export default router;
const CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID;
const REDIRECT_URI = 'http://127.0.0.1:8100/callback';
const SCOPES = ['playlist-read-private', 'playlist-read-collaborative'];
const AUTH_ENDPOINT = 'https://accounts.spotify.com/authorize';
const TOKEN_ENDPOINT = 'https://accounts.spotify.com/api/token';
function generateCodeVerifier(): string {
const array = new Uint8Array(64);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export async function redirectToSpotifyLogin() {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
scope: SCOPES.join(' '),
redirect_uri: REDIRECT_URI,
code_challenge_method: 'S256',
code_challenge: challenge,
state: verifier
});
window.location.href = `${AUTH_ENDPOINT}?${params.toString()}`;
}
export async function fetchAccessToken(code: string, verifier: string): Promise<string> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier
});
console.log('Enviando requisição para token com corpo:', Object.fromEntries(body.entries()));
const response = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body
});
const data = await response.json();
console.log('Resposta do token:', data);
if (!response.ok) {
throw new Error(`Erro ao obter token: ${response.status} - ${JSON.stringify(data)}`);
}
if (!data.access_token) {
throw new Error('access_token ausente na resposta');
}
localStorage.setItem('access_token', data.access_token);
return data.access_token;
}
export function getAccessToken(): string | null {
return localStorage.getItem('access_token');
}
export function logout() {
localStorage.removeItem('access_token');
}
:root {
--ion-color-primary: #3880ff;
--ion-color-primary-rgb: 56, 128, 255;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #3171e0;
--ion-color-primary-tint: #4c8dff;
--ion-color-secondary: #3dc2ff;
--ion-color-secondary-rgb: 61, 194, 255;
--ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #36abe0;
--ion-color-secondary-tint: #50c8ff;
--ion-color-tertiary: #5260ff;
--ion-color-tertiary-rgb: 82, 96, 255;
--ion-color-tertiary-contrast: #ffffff;
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
--ion-color-tertiary-shade: #4854e0;
--ion-color-tertiary-tint: #6370ff;
--ion-color-success: #2dd36f;
--ion-color-warning: #ffc409;
--ion-color-danger: #eb445a;
--ion-color-dark: #222428;
--ion-color-medium: #92949c;
--ion-color-light: #f4f5f8;
}
@media (prefers-color-scheme: dark) {
:root {
--ion-background-color: #121212;
--ion-text-color: #ffffff;
--ion-color-primary: #4c8dff;
--ion-color-secondary: #50c8ff;
--ion-color-tertiary: #6370ff;
--ion-color-success: #2fdf75;
--ion-color-warning: #ffd534;
--ion-color-danger: #ff4961;
--ion-color-dark: #f4f5f8;
--ion-color-medium: #989aa2;
--ion-color-light: #222428;
}
}
<template>
<ion-page>
<ion-header>
<ion-toolbar>
<ion-title>Autenticando...</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-spinner name="crescent" />
<p>Autenticando com Spotify...</p>
</ion-content>
</ion-page>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { fetchAccessToken } from '../services/auth';
const route = useRoute();
const router = useRouter();
onMounted(async () => {
const code = route.query.code as string;
const verifier = route.query.state as string; // ← novo
if (!code || !verifier) {
alert('Código de autorização ou estado ausente.');
router.replace('/');
return;
}
try {
console.log('Código recebido na URL:', code);
console.log('Verifier recebido via state:', verifier);
const token = await fetchAccessToken(code, verifier);
console.log('Token recebido com sucesso:', token);
} catch (error) {
console.error('Erro ao obter access_token:', error);
alert('Erro ao autenticar com o Spotify.');
} finally {
router.replace('/');
}
});
</script>
<template>
<PlaylistForm />
</template>
<script lang="ts" setup>
import PlaylistForm from '../components/PlaylistForm.vue';
</script>
<template>
<ion-app>
<ion-content class="ion-padding">
<router-view />
</ion-content>
</ion-app>
</template>
<script lang="ts" setup>
import { IonApp, IonContent } from '@ionic/vue';
</script>
<style scoped>
ion-content {
--background: var(--ion-background-color);
color: var(--ion-text-color);
}
</style>
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { IonicVue } from '@ionic/vue'
import '@ionic/vue/css/core.css'
import '@ionic/vue/css/normalize.css'
import '@ionic/vue/css/structure.css'
import '@ionic/vue/css/typography.css'
import '@ionic/vue/css/padding.css'
import '@ionic/vue/css/float-elements.css'
import '@ionic/vue/css/text-alignment.css'
import '@ionic/vue/css/text-transformation.css'
import '@ionic/vue/css/flex-utils.css'
import '@ionic/vue/css/display.css'
//import '@ionic/vue/css/palettes/dark.system.css'
import '@ionic/vue/css/palettes/dark.always.css'
import './theme/variables.css'
const app = createApp(App)
.use(IonicVue)
.use(router)
router.isReady().then(() => {
app.mount('#app')
})
VITE_SPOTIFY_CLIENT_ID=seu_client_id_aqui
VITE_SPOTIFY_CLIENT_SECRET=seu_client_secret_aqui
node_modules/
dist/
.env
Elaborado por Mateus Schwede
ubsocial.github.io