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:
Peter Stuifzand 2018-08-26 18:59:16 +02:00
parent 03a6ebf8a6
commit ddad588d90
12 changed files with 472 additions and 84 deletions

View File

@ -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"
]
}
}

View File

@ -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>

View 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>

View 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
View 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>

View File

@ -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>

View 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>

View 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>

View File

@ -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

View File

@ -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',

View File

@ -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)
}
}
})

View File

@ -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>