Create my first Microsub reader
All client side at the moment, but it will probably need a bit of backend code, to circumvent CORS problems in the browser.
This commit is contained in:
parent
03a6ebf8a6
commit
ddad588d90
|
@ -8,6 +8,7 @@
|
|||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-sass": "^4.9.3",
|
||||
"vue": "^2.5.17",
|
||||
"vue-router": "^3.0.1",
|
||||
"vuex": "^3.0.1"
|
||||
|
@ -42,4 +43,4 @@
|
|||
"last 2 versions",
|
||||
"not ie <= 8"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
52
src/App.vue
52
src/App.vue
|
@ -1,20 +1,48 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<div id="nav">
|
||||
<router-link to="/">Home</router-link> |
|
||||
<router-link to="/about">About</router-link>
|
||||
<nav id="nav" class="navbar is-primary">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<router-link to="/" class="navbar-item">Ekster</router-link>
|
||||
|
||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
<div class="navbar-menu">
|
||||
<!--<router-link to="/about">About</router-link>-->
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container">
|
||||
<router-view/>
|
||||
|
||||
<LoginModal :active="!this.$store.state.logged_in"></LoginModal>
|
||||
</div>
|
||||
<router-view/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoginModal from '@/components/LoginModal'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {LoginModal},
|
||||
mounted() {
|
||||
let loginData = JSON.parse(window.localStorage.getItem('login_data'))
|
||||
this.$store.dispatch('isLoggedIn', loginData)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
font-family: 'Avenir', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-align: center;
|
||||
color: #2c3e50;
|
||||
margin-top: 60px;
|
||||
}
|
||||
#app {
|
||||
font-family: 'Avenir', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #2c3e50;
|
||||
}
|
||||
</style>
|
||||
|
|
31
src/components/Channel.vue
Normal file
31
src/components/Channel.vue
Normal file
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<div class="channel--name" @click="select(channel)">
|
||||
{{ channel.name }} <span :class="{ tag: true, 'is-success': channel.unread > 0}">{{ channel.unread }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Channel",
|
||||
|
||||
props: ['channel'],
|
||||
|
||||
methods: {
|
||||
select(channel) {
|
||||
this.$emit('channel-selected', channel)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.channel--name {
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
.channel--name:hover {
|
||||
background: aliceblue;
|
||||
color: black;
|
||||
}
|
||||
</style>
|
17
src/components/Channels.vue
Normal file
17
src/components/Channels.vue
Normal file
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<div :class="this.className">
|
||||
<div class="channels--channel" v-for="channel in channels" :key="channel.uid">
|
||||
<slot :channel="channel"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Channels",
|
||||
props: ['className', 'channels']
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
99
src/components/Entry.vue
Normal file
99
src/components/Entry.vue
Normal file
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<div class="entry card">
|
||||
<div class="card-image" v-if="photo_first">
|
||||
<figure class="image is-4by3">
|
||||
<img :src="photo_first"/>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<img :src="item.author.photo"/>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div><a :href="author_url">{{ author_name }}</a></div>
|
||||
<h3 class="title is-6" v-if="item.name" v-text="item.name"></h3>
|
||||
<div class="content" v-html="main_content"></div>
|
||||
|
||||
<div class="photos">
|
||||
<div class="photo" v-for="photo in photo_rest" :key="photo">
|
||||
<img :src="photo" class="image"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="debug" v-text="item" v-if="this.$store.state.debug"></div>
|
||||
|
||||
<a :href="item.url">
|
||||
<span class="published" v-html="item.published"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Entry",
|
||||
props: ['item'],
|
||||
|
||||
computed: {
|
||||
main_content() {
|
||||
let content = this.item.content
|
||||
if (content) {
|
||||
if (content.html) {
|
||||
return content.html
|
||||
} else if (content.text) {
|
||||
return content.text
|
||||
}
|
||||
return content
|
||||
}
|
||||
content = this.item.summary
|
||||
if (content) {
|
||||
if (content.html) {
|
||||
return content.html
|
||||
} else if (content.text) {
|
||||
return content.text
|
||||
}
|
||||
return content
|
||||
}
|
||||
},
|
||||
photo_first() {
|
||||
if (this.item.photo && this.item.photo.length >= 1) {
|
||||
return this.item.photo[0]
|
||||
}
|
||||
return false
|
||||
},
|
||||
photo_rest() {
|
||||
if (this.item.photo && this.item.photo.length > 1) {
|
||||
return this.item.photo.slice(1)
|
||||
}
|
||||
return []
|
||||
},
|
||||
author_name() {
|
||||
if (!this.item.author.name) {
|
||||
return new URL(this.item.author.url).hostname
|
||||
}
|
||||
return this.item.author.name
|
||||
},
|
||||
author_url() {
|
||||
return this.item.author.url
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.entry {
|
||||
}
|
||||
|
||||
.photos {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-gap: 4px;
|
||||
}
|
||||
</style>
|
|
@ -1,58 +0,0 @@
|
|||
<template>
|
||||
<div class="hello">
|
||||
<h1>{{ msg }}</h1>
|
||||
<p>
|
||||
For guide and recipes on how to configure / customize this project,<br>
|
||||
check out the
|
||||
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
|
||||
</p>
|
||||
<h3>Installed CLI Plugins</h3>
|
||||
<ul>
|
||||
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
|
||||
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
|
||||
</ul>
|
||||
<h3>Essential Links</h3>
|
||||
<ul>
|
||||
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
|
||||
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
|
||||
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
|
||||
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
|
||||
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
|
||||
</ul>
|
||||
<h3>Ecosystem</h3>
|
||||
<ul>
|
||||
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
|
||||
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
|
||||
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
|
||||
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
|
||||
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'HelloWorld',
|
||||
props: {
|
||||
msg: String
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
h3 {
|
||||
margin: 40px 0 0;
|
||||
}
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
}
|
||||
a {
|
||||
color: #42b983;
|
||||
}
|
||||
</style>
|
96
src/components/LoginModal.vue
Normal file
96
src/components/LoginModal.vue
Normal file
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<div :class="classes">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Sign in with your website</p>
|
||||
<button class="delete" aria-label="close" @click="close"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<div class="field">
|
||||
<label class="label">Web Sign in</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" placeholder="https://example.com/" v-model="url">
|
||||
</div>
|
||||
<p class="help">Sign in with your website address</p>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<button class="button is-success" @click="login">Login</button>
|
||||
<button class="button" @click="close">Cancel</button>
|
||||
</footer>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" @click="close"></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import qs from 'qs'
|
||||
|
||||
export default {
|
||||
name: "LoginModal",
|
||||
|
||||
props: ['active'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
show: false,
|
||||
url: ''
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
classes() {
|
||||
return {'modal': true, 'is-active': this.show}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.show = false
|
||||
},
|
||||
login() {
|
||||
// try to login with
|
||||
window.location = 'https://p83.nl/auth?response_type=code&me=https://p83.nl/&redirect_uri=http://192.168.178.21:8080/callback&scope=channels+timeline&state=1234&client_id=https://p83.nl/';
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.show = this.active
|
||||
|
||||
if (!this.$route.query.hasOwnProperty('code')) {
|
||||
return
|
||||
}
|
||||
let code = this.$route.query['code']
|
||||
if (!code.length) {
|
||||
return
|
||||
}
|
||||
|
||||
let tokenurl = 'https://p83.nl/authtoken'
|
||||
let params = {}
|
||||
params['grant_type'] = 'authorization_code'
|
||||
params['code'] = code
|
||||
params['client_id'] = 'https://p83.nl/'
|
||||
params['redirect_uri'] = 'http://192.168.178.21:8080/callback'
|
||||
params['me'] = 'https://p83.nl/'
|
||||
|
||||
fetch(tokenurl, {
|
||||
method: 'POST',
|
||||
body: qs.stringify(params, {arrayFormat: 'brackets'}),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
}).then(response => response.json())
|
||||
.then(response => {
|
||||
this.$store.dispatch('tokenResponse', response)
|
||||
this.active = false
|
||||
this.$router.push('/')
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
49
src/components/Timeline.vue
Normal file
49
src/components/Timeline.vue
Normal file
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<div :class="this.className">
|
||||
<div class="timeline--item" v-for="item in items" :key="item.id">
|
||||
<TimelineEntry :item="item"/>
|
||||
</div>
|
||||
|
||||
<div class="level">
|
||||
<div class="level-item">
|
||||
<button class="button" @click="prevPage" v-if="timeline.paging.before">Prev Page</button>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<button class="button" @click="nextPage" v-if="timeline.paging.after">Next Page</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TimelineEntry from '@/components/Entry'
|
||||
|
||||
export default {
|
||||
name: "Timeline",
|
||||
props: ['className', 'channel', 'timeline'],
|
||||
components: {TimelineEntry},
|
||||
|
||||
computed: {
|
||||
items () {
|
||||
return this.timeline.items.filter((item) => {
|
||||
return item.type === 'entry';
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
nextPage() {
|
||||
this.$emit('getPage', {uid: this.channel.uid, after: this.timeline.paging.after})
|
||||
},
|
||||
prevPage() {
|
||||
this.$emit('getPage', {uid: this.channel.uid, before: this.timeline.paging.before})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timeline--item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
|
@ -2,6 +2,8 @@ import Vue from 'vue'
|
|||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
import 'bulma'
|
||||
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
|
|
|
@ -13,6 +13,16 @@ export default new Router({
|
|||
name: 'home',
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/channel/:uid',
|
||||
name: 'channel',
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/callback',
|
||||
name: 'callback',
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
|
|
68
src/store.js
68
src/store.js
|
@ -1,16 +1,76 @@
|
|||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
export default new Vuex.Store({
|
||||
state: {
|
||||
|
||||
state() {
|
||||
let loginData = JSON.parse(window.localStorage.getItem('login_data'))
|
||||
return {
|
||||
...loginData,
|
||||
channels: [],
|
||||
timeline: {items: [], paging: {}},
|
||||
channel: {},
|
||||
debug: false,
|
||||
logged_in: loginData.access_token && loginData.access_token.length > 0
|
||||
}
|
||||
},
|
||||
|
||||
mutations: {
|
||||
|
||||
newChannels(state, channels) {
|
||||
state.channels = channels
|
||||
},
|
||||
newTimeline(state, {channel, timeline}) {
|
||||
state.timeline = timeline
|
||||
state.channel = channel
|
||||
},
|
||||
newAccessToken(state, {me, access_token, scope}) {
|
||||
state.logged_in = true
|
||||
state.scope = scope
|
||||
state.me = me
|
||||
state.access_token = access_token
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
||||
actions: {
|
||||
fetchChannels({commit}) {
|
||||
fetch('https://microsub.stuifzandapp.com/microsub?action=channels', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + this.state.access_token
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
commit('newChannels', response.channels)
|
||||
})
|
||||
},
|
||||
fetchTimeline({commit}, channel) {
|
||||
let url = 'https://microsub.stuifzandapp.com/microsub?action=timeline&channel=' + channel.uid
|
||||
if (channel.after) {
|
||||
url += '&after=' + channel.after;
|
||||
}
|
||||
if (channel.before) {
|
||||
url += '&before=' + channel.before;
|
||||
}
|
||||
fetch(url, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + this.state.access_token
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
commit('newTimeline', {channel: channel, timeline: response})
|
||||
})
|
||||
},
|
||||
tokenResponse({commit}, response) {
|
||||
window.localStorage.setItem('login_data', JSON.stringify(response))
|
||||
commit('newAccessToken', response)
|
||||
},
|
||||
isLoggedIn({commit}, response) {
|
||||
// eslint-disable-next-line
|
||||
console.log(response)
|
||||
commit('newAccessToken', response)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,18 +1,71 @@
|
|||
<template>
|
||||
<div class="home">
|
||||
<img alt="Vue logo" src="../assets/logo.png">
|
||||
<HelloWorld msg="Welcome to Your Vue.js App"/>
|
||||
<div class="columns">
|
||||
<div class="column is-2">
|
||||
<Channels class="channels" :channels="this.$store.state.channels">
|
||||
<div slot-scope="{ channel }">
|
||||
<Channel :channel="channel" @channel-selected="selectChannel"></Channel>
|
||||
</div>
|
||||
</Channels>
|
||||
</div>
|
||||
<div class="column">
|
||||
<Timeline class="timeline" :timeline="this.$store.state.timeline" :channel="this.$store.state.channel"
|
||||
@getPage="getPage"></Timeline>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// @ is an alias to /src
|
||||
import HelloWorld from '@/components/HelloWorld.vue'
|
||||
// @ is an alias to /src
|
||||
import Timeline from '@/components/Timeline.vue'
|
||||
import Channels from '@/components/Channels.vue'
|
||||
import Channel from '@/components/Channel.vue'
|
||||
|
||||
export default {
|
||||
name: 'home',
|
||||
components: {
|
||||
HelloWorld
|
||||
export default {
|
||||
name: 'home',
|
||||
components: {
|
||||
Timeline,
|
||||
Channels,
|
||||
Channel
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
showChannels: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
uid() {
|
||||
return this.$route.params.uid || 'home';
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
selectChannel(channel) {
|
||||
this.$store.dispatch('fetchTimeline', channel)
|
||||
this.showChannels = false
|
||||
window.scrollTo({top: 0})
|
||||
},
|
||||
getPage(next) {
|
||||
this.$store.dispatch('fetchTimeline', next)
|
||||
window.scrollTo({top: 0})
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.$store.state.logged_in) {
|
||||
this.$store.dispatch('fetchChannels')
|
||||
this.$store.dispatch('fetchTimeline', {uid: this.uid})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timeline {
|
||||
margin-top: 20px;
|
||||
width: 600px;
|
||||
}
|
||||
</style>
|
Loading…
Reference in New Issue
Block a user