diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.html | 14 | ||||
| -rw-r--r-- | src/App.js | 8 | ||||
| -rw-r--r-- | src/App.scss | 383 | ||||
| -rw-r--r-- | src/App.vue | 30 | ||||
| -rw-r--r-- | src/components/Hello.vue | 44 | ||||
| -rw-r--r-- | src/components/hello/Hello.js | 4 | ||||
| -rw-r--r-- | src/components/public_timeline/public_timeline.js | 11 | ||||
| -rw-r--r-- | src/components/public_timeline/public_timeline.vue | 10 | ||||
| -rw-r--r-- | src/components/timeline/timeline.js | 7 | ||||
| -rw-r--r-- | src/components/timeline/timeline.vue | 7 | ||||
| -rw-r--r-- | src/main.js | 25 | ||||
| -rw-r--r-- | src/modules/statuses.js | 100 | ||||
| -rw-r--r-- | src/services/api/api.service.js | 101 | ||||
| l--------- | src/services/timeline_fetcher/.#timeline_fetcher.service.js | 1 | ||||
| -rw-r--r-- | src/services/timeline_fetcher/timeline_fetcher.service.js | 53 |
15 files changed, 724 insertions, 74 deletions
diff --git a/src/App.html b/src/App.html new file mode 100644 index 00000000..1b067684 --- /dev/null +++ b/src/App.html @@ -0,0 +1,14 @@ +<div id="app"> + <nav class='container'> + <div class='item'> + <a route-to='friends-timeline' href="#">Pleroma FE</a> + </div> + </nav> + <div class="container" id="content"> + <sidebar> + <user-panel></user-panel> + <nav-panel></nav-panel> + </sidebar> + <router-view></router-view> + </div> +</div> diff --git a/src/App.js b/src/App.js new file mode 100644 index 00000000..1a7c41d6 --- /dev/null +++ b/src/App.js @@ -0,0 +1,8 @@ +import Hello from './components/hello/Hello' + +export default { + name: 'app', + components: { + Hello + } +} diff --git a/src/App.scss b/src/App.scss new file mode 100644 index 00000000..3c04d020 --- /dev/null +++ b/src/App.scss @@ -0,0 +1,383 @@ +$main-color: #f58d2c; +$main-background: white; +$darkened-background: whitesmoke; + +body { + background-color: $main-color; + background-size: cover; + background-attachment: fixed; + background-repeat: no-repeat; + background-position: 0 50px; +} + +h4 { + margin: 0; +} + +#content { + padding-top: 60px; +} + +.text-center { + text-align: center; +} + +body { + font-family: sans-serif; + font-size: 14px; + margin: 0; +} + +a { + text-decoration: none; + color: $main-color; +} + +.container { + display: flex; + margin: 0; + padding: 0 10px 0 10px; +} + +form { + display: flex; + flex-direction: column; + padding: 0.5em; +} + +.form-group { + display: flex; + flex-direction: column; + padding: 0.5em; +} + +.gaps { + margin: -1em 0 0 -1em; +} + +.item { + flex: 1; +} + +.gaps > .item { + padding: 1em 0 0 1em; +} + +.auto-size { + flex: 1 +} + +nav { + background: black; + width: 100%; + align-items: center; + position: fixed; + height: 50px; + +} + +sidebar { + width: 33.333% !important; +} + +main-router { + flex: 1; +} + +.status:hover { + background-color: $darkened-background; +} + +/* The starting CSS styles for the enter animation */ +status.ng-enter { + transition:0.5s linear all; + opacity:0; +} + +/* The finishing CSS styles for the enter animation */ +status.ng-enter.ng-enter-active { + opacity:1; +} + +.new-status-notification { + font-size: 1.1em; + background-color: $darkened-background; + border-bottom-color: darken($darkened-background, 5%); + border-bottom-style: solid; + border-bottom-width: 1px; + + &:hover { + background-color: darken($darkened-background, 5%); + } + + p { + margin: 0px; + padding: 10px; + } +} + +.status.compact { + color: rgba(0, 0, 0, 0.42); + font-weight: 300; + + p { + margin: 0; + font-size: 0.8em + } +} + +/* Panel */ + +.panel { + display: flex; + flex-direction: column; + background-color: $main-background; + margin: 0.5em; + + border-radius: 0.5em; +} + +.panel-heading { + border-radius: 0.5em 0.5em 0 0; + background-size: cover; + background-color: bisque; + padding-top: 0.3em; + padding-bottom: 0.3em; + text-align: center; + font-size: 1.3em; +} + +.panel-footer { + background-color: bisque; + border-radius: 0 0 0.5em 0.5em; +} + +.panel-body > p { + margin: 1em; +} + +.attachments { + display: flex; + flex-wrap: wrap; +} + +.attachment, attachment { + flex: 1 0 30%; + display: flex; + margin: 0.2em; + align-self: flex-start; + + img { + border: 1px solid; + border-radius: 0.5em; + width: 100%; + } + + video { + border: 1px solid; + border-radius: 0.5em; + width: 100%; + } + + .oembed { + border: 1px solid rgba(0, 0, 0, 0.14); + width: 100%; + + display: flex; + .image { + flex: 1; + display: flex; + img { + border: 0px; + border-radius: 0; + } + } + + .text { + flex: 2; + margin: 8px; + h1 { + font-size: 14px; + margin: 0px; + + a { + color: black; + } + } + } + } +} + +.media-body { + flex: 1 +} + +#content { + margin: auto; + max-width: 920px; +} + +.media-left { + width: 10% !important; +} + +.media-body { + flex: 1; + padding-left: 0.3em; +} + +.status .avatar { + width: 48px; +} + +.status.compact .avatar { + width: 32px; +} + +.status { + padding: 0.5em; + padding-right: 1em; + border-bottom: 1px solid silver; +} + +.status-el:last-child .status { + border: none +} + +[ng-click] { + cursor: pointer; +} + +nav-panel ul { + list-style: none; + margin: 0; + padding: 0; +} + +nav-panel li { + border-bottom: 1px solid silver; + padding: 0.5em; + padding-left: 1em; +} + +nav-panel li:last-child { + border: none; +} + +nav-panel a { + display: block; + width: 100%; +} + +.status-el p { + margin: 0; + margin-top: 0.2em; + margin-bottom: 0.5em; +} + +.user-info { + padding: 1em; + img { + border: 3px solid; + border-radius: 0.5em + } + + .user-screen-name { + font-weight: lighter; + } +} + +.user-counts { + display: flex; + padding: 1em 1em 0em 1em; +} + +.user-count { + flex: 1; + + h5 { + font-weight: lighter; + margin: 0; + } + + span { + color: $main-color; + } +} + +.fa { + color: $main-color; +} + +.status-actions { + width: 50%; + display: flex; + + div, favorite-button { + flex: 1; + } +} + +status-text-container { + display: block; +} + +attention { + color: $main-color; +} + +.form-bottom { + display: flex; + padding: 0.5em; + + media-upload { + font-size: 26px; + flex: 1; + } + + button { + flex: 2; + } +} + +.status-el { + line-height: 18px; + + .notify { + .avatar { + border-width: 3px; + border-color: $main-color; + border-style: solid; + } + } + + .media-left { + img { + margin-top: 0.2em; + float: right; + margin-right: 0.3em; + border-radius: 20%; + } + } + + .retweet-info { + padding: 0.3em; + + .media-left { + display: flex; + + i { + align-self: center; + text-align: right; + flex: 1; + padding-right: 0.3em; + } + } + } + + .media-heading { + small { + font-weight: lighter; + } + } +} diff --git a/src/App.vue b/src/App.vue index 48741ed1..e7e464df 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,28 +1,4 @@ -<template> - <div id="app"> - <img src="./assets/logo.png"> - <hello></hello> - </div> -</template> +<template src="./App.html"></template> -<script> -import Hello from './components/hello/Hello' - -export default { - name: 'app', - components: { - Hello - } -} -</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; -} -</style> +<script src="./App.js"></script> +<style lang="scss" src="./App.scss"></style> diff --git a/src/components/Hello.vue b/src/components/Hello.vue deleted file mode 100644 index 58ca7687..00000000 --- a/src/components/Hello.vue +++ /dev/null @@ -1,44 +0,0 @@ -<template> - <div class="hello"> - <h1>{{ msg }}</h1> - <h2>Essential Links</h2> - <ul> - <li><a href="https://vuejs.org" target="_blank">Core Docs</a></li> - <li><a href="https://forum.vuejs.org" target="_blank">Forum</a></li> - <li><a href="https://gitter.im/vuejs/vue" target="_blank">Gitter Chat</a></li> - <li><a href="https://twitter.com/vuejs" target="_blank">Twitter</a></li> - <br> - <li><a href="http://vuejs-templates.github.io/webpack/" target="_blank">Docs for This Template</a></li> - </ul> - <h2>Ecosystem</h2> - <ul> - <li><a href="http://router.vuejs.org/" target="_blank">vue-router</a></li> - <li><a href="http://vuex.vuejs.org/" target="_blank">vuex</a></li> - <li><a href="http://vue-loader.vuejs.org/" target="_blank">vue-loader</a></li> - <li><a href="https://github.com/vuejs/awesome-vue" target="_blank">awesome-vue</a></li> - </ul> - </div> -</template> - -<script src='./Hello.js' /> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -h1, h2 { - font-weight: normal; -} - -ul { - list-style-type: none; - padding: 0; -} - -li { - display: inline-block; - margin: 0 10px; -} - -a { - color: #42b983; -} -</style> diff --git a/src/components/hello/Hello.js b/src/components/hello/Hello.js index 609bdbdc..c701c560 100644 --- a/src/components/hello/Hello.js +++ b/src/components/hello/Hello.js @@ -3,6 +3,6 @@ export default { data () { return { msg: 'Welcome to Your Vue.js app' - }; + } } -}; +} diff --git a/src/components/public_timeline/public_timeline.js b/src/components/public_timeline/public_timeline.js new file mode 100644 index 00000000..cac422ec --- /dev/null +++ b/src/components/public_timeline/public_timeline.js @@ -0,0 +1,11 @@ +import Timeline from '../timeline/timeline.vue' +const PublicTimeline = { + components: { + Timeline + }, + computed: { + timeline () { return this.$store.state.statuses.timelines.public } + } +} + +export default PublicTimeline diff --git a/src/components/public_timeline/public_timeline.vue b/src/components/public_timeline/public_timeline.vue new file mode 100644 index 00000000..0ed8baf8 --- /dev/null +++ b/src/components/public_timeline/public_timeline.vue @@ -0,0 +1,10 @@ +<template> + <div class="panel panel-default"> + <div class="panel-heading">Public Timeline</div> + <div class="panel-body"> + <Timeline v-bind:timeline="timeline" /> + </div> + </div> +</template> + +<script src="./public_timeline.js"></script> diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js new file mode 100644 index 00000000..727008ad --- /dev/null +++ b/src/components/timeline/timeline.js @@ -0,0 +1,7 @@ +const Timeline = { + props: [ + 'timeline' + ] +} + +export default Timeline; diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue new file mode 100644 index 00000000..cf712796 --- /dev/null +++ b/src/components/timeline/timeline.vue @@ -0,0 +1,7 @@ +<template> + <div class="timeline"> + <h1>Timeline goes here</h1> + <h2 v-for="status in timeline.visibleStatuses">{{status.text}}</h2> + </div> +</template> +<script src="./timeline.js"></script> diff --git a/src/main.js b/src/main.js index 15369fa8..b2ee137c 100644 --- a/src/main.js +++ b/src/main.js @@ -1,8 +1,31 @@ import Vue from 'vue' -import App from './App' +import VueRouter from 'vue-router' +import Vuex from 'vuex' +import App from './App.vue' +import PublicTimeline from './components/public_timeline/public_timeline.vue' + +import statuses from './modules/statuses.js' + +Vue.use(Vuex) +Vue.use(VueRouter) + +const store = new Vuex.Store({ + modules: { + statuses + } +}) + +const routes = [ + { path: '/', redirect: '/main/public' }, + { path: '/main/public', component: PublicTimeline } +] + +const router = new VueRouter({routes}) /* eslint-disable no-new */ new Vue({ + router, + store, el: '#app', template: '<App/>', components: { App } diff --git a/src/modules/statuses.js b/src/modules/statuses.js new file mode 100644 index 00000000..b1a77061 --- /dev/null +++ b/src/modules/statuses.js @@ -0,0 +1,100 @@ +import { last, intersectionBy, sortBy, unionBy, toInteger, groupBy, differenceBy, each, find } from 'lodash' +// import moment from 'moment' + +const defaultState = { + allStatuses: [], + maxId: 0, + timelines: { + public: { + statuses: [], + faves: [], + visibleStatuses: [], + newStatusCount: 0, + maxId: 0, + minVisibleId: 0 + }, + publicAndExternal: { + statuses: [], + faves: [], + visibleStatuses: [], + newStatusCount: 0, + maxId: 0, + minVisibleId: 0 + }, + friends: { + statuses: [], + faves: [], + visibleStatuses: [], + newStatusCount: 0, + maxId: 0, + minVisibleId: 0 + } + } +} + +const statusType = (status) => { + return !status.is_post_verb && status.uri.match(/fave/) ? 'fave' : 'status' +} + +const addStatusesToTimeline = (addedStatuses, showImmediately, { statuses, visibleStatuses, newStatusCount, faves }) => { + const statusesAndFaves = groupBy(addedStatuses, statusType) + const addedFaves = statusesAndFaves['fave'] || [] + const unseenFaves = differenceBy(addedFaves, faves, 'id') + + // Update fave count + each(unseenFaves, ({in_reply_to_status_id}) => { + const status = find(statuses, { id: toInteger(in_reply_to_status_id) }) + if (status) { + status.fave_num += 1 + } + }) + + addedStatuses = statusesAndFaves['status'] || [] + + // Add some html to the statuses. + each(addedStatuses, (status) => { + const statusoid = status.retweeted_status || status + if (statusoid.parsedText === undefined) { + // statusoid.parsedText = statusParserService.parse(statusoid) + statusoid.parsedText = statusoid.text + } + }) + + const newStatuses = sortBy( + unionBy(addedStatuses, statuses, 'id'), + ({id}) => -id + ) + + let newNewStatusCount = newStatusCount + (newStatuses.length - statuses.length) + + let newVisibleStatuses = visibleStatuses + + if (showImmediately) { + newVisibleStatuses = unionBy(addedStatuses, newVisibleStatuses, 'id') + newVisibleStatuses = sortBy(newVisibleStatuses, ({id}) => -id) + newNewStatusCount = newStatusCount + }; + + newVisibleStatuses = intersectionBy(newStatuses, newVisibleStatuses, 'id') + + return { + statuses: newStatuses, + visibleStatuses: newVisibleStatuses, + newStatusCount: newNewStatusCount, + maxId: newStatuses[0].id, + minVisibleId: last(newVisibleStatuses).id, + faves: unionBy(faves, addedFaves, 'id') + } +} + +const statuses = { + state: defaultState, + mutations: { + addNewStatuses (state, { statuses, showImmediately = false, timeline }) { + state.timelines[timeline] = addStatusesToTimeline(statuses, showImmediately, state.timelines[timeline]) + state.allStatuses = unionBy(state.timelines[timeline].statuses, state.allStatuses.id) + } + } +} + +export default statuses diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js new file mode 100644 index 00000000..3eb8c62a --- /dev/null +++ b/src/services/api/api.service.js @@ -0,0 +1,101 @@ +const LOGIN_URL='/api/account/verify_credentials.json'; +const FRIENDS_TIMELINE_URL='/api/statuses/friends_timeline.json'; +const PUBLIC_TIMELINE_URL='/api/statuses/public_timeline.json'; +const PUBLIC_AND_EXTERNAL_TIMELINE_URL='/api/statuses/public_and_external_timeline.json'; +const CONVERSATION_URL = '/api/statusnet/conversation/'; +const STATUS_UPDATE_URL = '/api/statuses/update.json'; +const MEDIA_UPLOAD_URL = '/api/statusnet/media/upload'; +const FAVORITE_URL = '/api/favorites/create'; +const UNFAVORITE_URL = '/api/favorites/destroy'; + +const FORM_CONTENT_TYPE = {'Content-Type': 'application/x-www-form-urlencoded'}; + +import { param, ajax } from 'jquery'; +import { merge } from 'lodash'; + +// TODO: This should probably be in redux. +let authHeaders = {}; + +const apiServiceFactory = ($http) => { + // Public + const fetchConversation = (id) => { + return $http.get(`${CONVERSATION_URL}/${id}.json?count=100`); + }; + + const fetchTimeline = ({timeline, since = false, until = false}) => { + const timelineUrls = { + public: PUBLIC_TIMELINE_URL, + friends: FRIENDS_TIMELINE_URL, + 'public-and-external': PUBLIC_AND_EXTERNAL_TIMELINE_URL + }; + + let url = timelineUrls[timeline]; + + if(since) { + url += `?since_id=${since}`; + } + + if(until) { + url += `?max_id=${until}`; + } + + return fetch(url, { headers: authHeaders }).then((data) => data.json()); + }; + + // Need credentials + const verifyCredentials = (user) => { + const base64 = btoa(`${user.username}:${user.password}`); + authHeaders = { "Authorization": `Basic ${base64}` }; + return $http.post(LOGIN_URL, null, { headers: authHeaders }); + }; + + const postStatus = ({status, mediaIds, in_reply_to_status_id}) => { + const idsText = mediaIds.join(','); + const form = new FormData(); + + form.append('status', status); + form.append('source', 'The Wired FE'); + form.append('media_ids', idsText); + if(in_reply_to_status_id) { + form.append('in_reply_to_status_id', in_reply_to_status_id); + }; + + return fetch(STATUS_UPDATE_URL, { + body: form, + method: 'POST', + headers: authHeaders + }); + }; + + const favorite = (id) => $http.post(`${FAVORITE_URL}/${id}.json`, null, {headers: authHeaders}); + const unfavorite = (id) => $http.post(`${UNFAVORITE_URL}/${id}.json`, null, {headers: authHeaders}); + + // This was impossible to get to work with $http. You're supposed to set Content-Type + // undefined in the header so it sends the correct header. It would always send a json + // content type. This method from jQuery worked right away... + // Also, this method is only available as XML output. OLOLOLOLO + const uploadMedia = (formData) => ajax({ + url: MEDIA_UPLOAD_URL, + data: formData, + type: 'POST', + processData: false, + contentType: false, + headers: authHeaders + }); + + const apiService = { + verifyCredentials, + fetchConversation, + postStatus, + uploadMedia, + favorite, + unfavorite, + fetchTimeline + }; + + return apiService; +}; + +apiServiceFactory.$inject = ['$http']; + +export default apiServiceFactory; diff --git a/src/services/timeline_fetcher/.#timeline_fetcher.service.js b/src/services/timeline_fetcher/.#timeline_fetcher.service.js new file mode 120000 index 00000000..8315cdae --- /dev/null +++ b/src/services/timeline_fetcher/.#timeline_fetcher.service.js @@ -0,0 +1 @@ +roger@yuuyuu.18961
\ No newline at end of file diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js new file mode 100644 index 00000000..9013c82e --- /dev/null +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -0,0 +1,53 @@ +import { upperFirst, camelCase } from 'lodash'; + +const timelineFetcherServiceFactory = ($ngRedux, apiService, $interval) => { + let fetcher; + + const update = ({statuses, timeline, showImmediately}) => { + const ccTimeline = camelCase(timeline); + + const action = { + type: 'ADD_NEW_STATUSES', + data: { + statuses, + timeline: ccTimeline, + showImmediately + } + }; + + $ngRedux.dispatch(action); + $ngRedux.dispatch({type: 'UPDATE_TIMESTAMPS'}); + }; + + const fetchAndUpdate = ({timeline = 'friends', older = false, showImmediately = false}) => { + const args = { timeline }; + const timelineData = $ngRedux.getState().statuses.timelines[camelCase(timeline)]; + + if(older) { + args['until'] = timelineData.minVisibleId; + } else { + args['since'] = timelineData.maxId; + } + + apiService.fetchTimeline(args). + then((statuses) => update({statuses, timeline, showImmediately})); + }; + + const startFetching = ({timeline = 'friends'}) => { + fetchAndUpdate({timeline, showImmediately: true}); + + const boundFetchAndUpdate = () => fetchAndUpdate({timeline}); + fetcher = $interval(boundFetchAndUpdate, 10000); + }; + + const timelineFetcherService = { + startFetching, + fetchAndUpdate + }; + + return timelineFetcherService; +}; + +timelineFetcherServiceFactory.$inject = ['$ngRedux', 'apiService', '$interval']; + +export default timelineFetcherServiceFactory; |
