pixelfed/pixelfed

View on GitHub
resources/assets/js/components/SearchResults.vue

Summary

Maintainability
Test Coverage
<template>
<div class="container">
    <div v-if="loading" class="pt-5 text-center">
        <div class="spinner-border" role="status">
            <span class="sr-only">Loading…</span>
        </div>
    </div>
    <div v-if="networkError" class="pt-5 text-center">
        <p class="lead font-weight-lighter">An error occured, results could not be loaded.<br> Please try again later.</p>
    </div>

    <div v-if="!loading && !networkError" class="mt-5">
        <div v-if="analysis == 'all'" class="row">
            <div class="col-12 d-flex justify-content-between align-items-center">
                <p class="h5 font-weight-bold text-dark">Showing results for <i>{{query}}</i></p>
                <div v-if="placesSearchEnabled" title="Show Places" data-toggle="tooltip">
                    <span v-if="results.placesPagination.total > 0" class="badge badge-light mr-2 p-1 border" style="margin-top:-5px;">{{formatCount(results.placesPagination.total)}}</span>
                    <div class="d-inline custom-control custom-switch">
                        <input type="checkbox" class="custom-control-input" id="placesSwitch" v-model="showPlaces">
                        <label class="custom-control-label font-weight-bold text-sm text-lighter" for="placesSwitch"><i class="fas fa-map-marker-alt"></i></label>
                    </div>
                </div>
            </div>
            <div class="col-12 mb-5">
                <hr>
            </div>
            <div v-if="placesSearchEnabled && showPlaces" class="col-12 mb-4">
                <div class="mb-4">
                    <p class="text-secondary small font-weight-bold">PLACES <span class="pl-1 text-lighter">({{results.placesPagination.total}})</span></p>
                </div>
                <div v-if="results.places.length" class="mb-5">
                    <a v-for="(hashtag, index) in results.places" class="mr-3 pr-4 d-inline-block text-decoration-none" :href="buildUrl('places', hashtag)">
                        <div class="pb-2">
                            <div class="media align-items-center py-2">
                                <div class="media-body text-truncate">
                                    <p class="mb-0 text-break text-dark font-weight-bold" data-toggle="tooltip" :title="hashtag.value">
                                        <i class="fas fa-map-marker-alt text-lighter mr-2"></i> {{hashtag.value}}
                                    </p>
                                </div>
                            </div>
                        </div>
                    </a>
                    <p v-if="results.places.length == 20 || placesCursor > 0" class="text-center mt-3">
                        <a v-if="placesCursor == 1" href="#" class="btn btn-outline-secondary btn-sm font-weight-bold py-0 disabled" disabled>
                            <i class="fas fa-chevron-left mr-2"></i> Previous
                        </a>
                        <a v-else href="#" @click.prevent="placesPrevPage()" class="btn btn-outline-secondary btn-sm font-weight-bold py-0">
                            <i class="fas fa-chevron-left mr-2"></i> Previous
                        </a>

                        <span class="mx-4 small text-lighter">{{placesCursor}}/{{results.placesPagination.last_page}}</span>

                        <a v-if="placesCursor !== results.placesPagination.last_page" @click.prevent="placesNextPage()" href="#" class="btn btn-primary btn-sm font-weight-bold py-0">
                            Next <i class="fas fa-chevron-right ml-2"></i>
                        </a>
                        <a v-else href="#" class="btn btn-primary btn-sm font-weight-bold py-0 disabled" disabled>
                            Next <i class="fas fa-chevron-right ml-2"></i>
                        </a>
                    </p>
                </div>
                <div v-else>
                    <div class="border py-3 text-center font-weight-bold">No results found</div>
                </div>            
            </div>
            <div class="col-md-3">
                <div class="mb-4">
                    <p class="text-secondary small font-weight-bold">HASHTAGS <span class="pl-1 text-lighter">({{results.hashtags.length}})</span></p>
                </div>
                <div v-if="results.hashtags.length">
                    <a v-for="(hashtag, index) in results.hashtags" class="mb-2 result-card" :href="buildUrl('hashtag', hashtag)">
                        <div class="pb-3">
                            <div class="media align-items-center py-2 pr-3">
                                <span class="d-inline-flex align-items-center justify-content-center border rounded-circle mr-3" style="width: 50px;height: 50px;">
                                <i class="fas fa-hashtag text-muted"></i>
                                </span>
                                <div class="media-body text-truncate">
                                    <p class="mb-0 text-break text-dark font-weight-bold" data-toggle="tooltip" :title="hashtag.value">
                                        #{{hashtag.value}}
                                    </p>
                                    <p v-if="hashtag.count > 2" class="mb-0 small font-weight-bold text-muted text-uppercase">
                                    {{hashtag.count}} posts
                                </p>
                                </div>
                            </div>
                        </div>
                    </a>
                </div>
                <div v-else>
                    <div class="border py-3 text-center font-weight-bold">No results found</div>
                </div>
            </div>
            <div class="col-md-5">
                <div class="mb-4">
                    <p class="text-secondary small font-weight-bold">PROFILES <span class="pl-1 text-lighter">({{results.profiles.length}})</span></p>
                </div>
                <div v-if="results.profiles.length">
                    <a v-for="(profile, index) in results.profiles" class="mb-2 result-card" :href="buildUrl('profile', profile)">
                        <div class="pb-3">
                            <div class="media align-items-center py-2 pr-3">
                                <img class="mr-3 rounded-circle border" :src="profile.avatar" width="50px" height="50px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
                                <div class="media-body">
                                    <p class="mb-0 text-break text-dark font-weight-bold" data-toggle="tooltip" :title="profile.value">
                                        {{profile.value}}
                                    </p>
                                    <p class="mb-0 small font-weight-bold text-muted text-uppercase">
                                        {{profile.entity.post_count}} Posts
                                    </p>
                                </div>
                                <div class="ml-3">
                                    <a v-if="profile.entity.following" class="btn btn-primary btn-sm font-weight-bold text-uppercase py-0" :href="buildUrl('profile', profile)">Following</a>
                                    <a v-else class="btn btn-outline-primary btn-sm font-weight-bold text-uppercase py-0" :href="buildUrl('profile', profile)">View</a>
                                </div>
                            </div>
                        </div>
                    </a>
                </div>
                <div v-else>
                    <div class="border py-3 text-center font-weight-bold">No results found</div>
                </div>
            </div>
            <div class="col-md-4">
                <div class="mb-4">
                    <p class="text-secondary small font-weight-bold">STATUSES <span class="pl-1 text-lighter">({{results.statuses.length}})</span></p>
                </div>
                <div v-if="results.statuses.length">
                    <a v-for="(status, index) in results.statuses" :key="'srs:'+index" class="mr-2 result-card" :href="buildUrl('status', status)">
                        <img :src="status.thumb" width="90px" height="90px" class="mb-2" onerror="this.onerror=null;this.src='/storage/no-preview.png?v=0';" v-once>
                    </a>
                </div>
                <div v-else>
                    <div class="border py-3 text-center font-weight-bold">No results found</div>
                </div>
            </div>
        </div>
        <div v-else-if="analysis == 'hashtag'" class="row">
            <div class="col-12 mb-5">
                <p class="h5 font-weight-bold text-dark">Showing results for <i>{{query}}</i></p>
                <hr>
            </div>
            <div class="col-md-6 offset-md-3">
                <div class="mb-4">
                    <p class="text-secondary small font-weight-bold">HASHTAGS <span class="pl-1 text-lighter">({{results.hashtags.length}})</span></p>
                </div>
                <div v-if="results.hashtags.length">
                    <a v-for="(hashtag, index) in results.hashtags" class="mb-2 result-card" :href="buildUrl('hashtag', hashtag)">
                        <div class="pb-3">
                            <div class="media align-items-center py-2 pr-3">
                                <span class="d-inline-flex align-items-center justify-content-center border rounded-circle mr-3" style="width: 50px;height: 50px;">
                                <i class="fas fa-hashtag text-muted"></i>
                                </span>
                                <div class="media-body">
                                    <p class="mb-0 text-truncate text-dark font-weight-bold" data-toggle="tooltip" :title="hashtag.value">
                                        #{{hashtag.value}}
                                    </p>
                                    <p v-if="hashtag.count > 2" class="mb-0 small font-weight-bold text-muted text-uppercase">
                                    {{hashtag.count}} posts
                                </p>
                                </div>
                            </div>
                        </div>
                    </a>
                </div>
                <div v-else>
                    <div class="border py-3 text-center font-weight-bold">No results found</div>
                </div>
            </div>
        </div>
        <div v-else-if="analysis == 'profile'" class="row">
            <div class="col-12 mb-5">
                <p class="h5 font-weight-bold text-dark">Showing results for <i>{{query}}</i></p>
                <hr>
            </div>
            <div class="col-md-6 offset-md-3">
                <div class="mb-4">
                    <p class="text-secondary small font-weight-bold">PROFILES <span class="pl-1 text-lighter">({{results.profiles.length}})</span></p>
                </div>
                <div v-if="results.profiles.length">
                    <div v-for="(profile, index) in results.profiles" class="card mb-4">
                        <div class="card-header p-0 m-0">
                            <div style="width: 100%;height: 140px;background: #0070b7"></div>
                        </div>
                        <div class="card-body">
                            <div class="text-center mt-n5 mb-4">
                                <img class="rounded-circle p-1 border mt-n4 bg-white shadow" :src="profile.entity.thumb" width="90px" height="90px;" onerror="this.onerror=null;this.src='/storage/avatars/default.png';">
                            </div>
                            <p class="text-center lead font-weight-bold mb-1">{{profile.value}}</p>
                            <p class="text-center text-muted small text-uppercase mb-4"><!-- 2 followers --></p>
                            <div class="d-flex justify-content-center">
                                <button v-if="profile.entity.following" type="button" class="btn btn-outline-secondary btn-sm py-1 px-4 text-uppercase font-weight-bold mr-3" style="font-weight: 500">Following</button>
                                <a class="btn btn-primary btn-sm py-1 px-4 text-uppercase font-weight-bold" :href="buildUrl('profile',profile)" style="font-weight: 500">View Profile</a>
                            </div>
                        </div>
                    </div>
                </div>
                <div v-else>
                    <div class="border py-3 text-center font-weight-bold">No results found</div>
                </div>
            </div>
        </div>
        <div v-else-if="analysis == 'webfinger'" class="row">
            <div class="col-12 mb-5">
                <p class="h5 font-weight-bold text-dark">Showing results for <i>{{query}}</i></p>
                <hr>
                <div class="col-md-6 offset-md-3">
                    <div v-for="(profile, index) in results.profiles" class="card mb-2">
                        <div class="card-header p-0 m-0">
                            <div style="width: 100%;height: 140px;background: #0070b7"></div>
                        </div>
                        <div class="card-body">
                            <div class="text-center mt-n5 mb-4">
                                <img class="rounded-circle p-1 border mt-n4 bg-white shadow" :src="profile.entity.thumb" width="90px" height="90px;" onerror="this.onerror=null;this.src='/storage/avatars/default.png';">
                            </div>
                            <p class="text-center lead font-weight-bold mb-1">{{profile.value}}</p>
                            <p class="text-center text-muted small text-uppercase mb-4"><!-- 2 followers --></p>
                            <div class="d-flex justify-content-center">
                                <!-- <button v-if="profile.entity.following" type="button" class="btn btn-outline-secondary btn-sm py-1 px-4 text-uppercase font-weight-bold mr-3" style="font-weight: 500">Unfollow</button> -->
                                <!-- <button v-else type="button" class="btn btn-primary btn-sm py-1 px-4 text-uppercase font-weight-bold mr-3" style="font-weight: 500">Follow</button> -->
                                <a class="btn btn-primary btn-sm py-1 px-4 text-uppercase font-weight-bold" :href="'/i/web/profile/_/' + profile.entity.id" style="font-weight: 500">View Profile</a>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <div v-else-if="analysis == 'remote'" class="row">
            <div class="col-12 mb-5">
                <p class="h5 font-weight-bold text-dark">Showing results for <i>{{query}}</i></p>
                <hr>
            </div>
            <div v-if="results.profiles.length" class="col-md-6 offset-3">
                <a v-for="(profile, index) in results.profiles" class="mb-2 result-card" :href="buildUrl('profile', profile)">
                    <div class="pb-3">
                        <div class="media align-items-center py-2 pr-3">
                            <img class="mr-3 rounded-circle border" :src="profile.entity.thumb" width="50px" height="50px" onerror="this.onerror=null;this.src='/storage/avatars/default.png';">
                            <div class="media-body">
                                <p class="mb-0 text-truncate text-dark font-weight-bold" data-toggle="tooltip" :title="profile.value">
                                    {{profile.value}}
                                </p>
                                <p class="mb-0 small font-weight-bold text-muted text-uppercase">
                                    {{profile.entity.post_count}} Posts
                                </p>
                            </div>
                            <div class="ml-3">
                                <a v-if="profile.entity.following" class="btn btn-primary btn-sm font-weight-bold text-uppercase py-0" :href="buildUrl('profile', profile)">Following</a>
                                <a v-else class="btn btn-outline-primary btn-sm font-weight-bold text-uppercase py-0" :href="buildUrl('profile', profile)">View</a>
                            </div>
                        </div>
                    </div>
                </a>
            </div>
            <div v-if="results.statuses.length" class="col-md-6 offset-3">
                <a v-for="(status, index) in results.statuses" class="mr-2 result-card" :href="buildUrl('status', status)">
                    <img :src="status.thumb" width="90px" height="90px" class="mb-2" onerror="this.onerror=null;this.src='/storage/no-preview.png';">
                </a>
            </div>
        </div>
        <div v-else-if="analysis == 'remotePost'" class="row">
            <div class="col-12 mb-5">
                <p class="h5 font-weight-bold text-dark">Showing results for <i>{{query}}</i></p>
                <hr>
            </div>
            <div class="col-md-6 offset-md-3">
                <div v-if="results.statuses.length">
                    <div v-for="(status, index) in results.statuses" class="card mb-4 shadow-none border">
                        <div class="card-header p-0 m-0">
                            <div style="width: 100%;height: 200px;background: #fff">
                                <div class="pt-4 text-center">
                                    <img :src="status.thumb" class="img-fluid border" style="max-height: 140px;">
                                </div>
                            </div>
                        </div>
                        <div class="card-body">
                            <div class="mt-n4 mb-2">
                                <div class="media">
                                    
                                    <img class="rounded-circle p-1 mr-2 border mt-n3 bg-white shadow" src="/storage/avatars/default.png" width="70px" height="70px;" onerror="this.onerror=null;this.src='/storage/avatars/default.png';">
                                    <div class="media-body pt-3">
                                        <p class="font-weight-bold mb-0">{{status.username}}</p>
                                    </div>
                                    <div class="float-right pt-3">
                                        <p class="small mb-0 text-muted">{{status.timestamp}}</p>
                                    </div>
                                </div>
                            </div>
                            <p class="text-center mb-3 lead" v-html="status.caption"></p>
                            <!-- <p class="text-center text-muted small text-uppercase mb-4">2 likes</p> -->
                            <!-- <div class="d-flex justify-content-center">
                                <a class="btn btn-primary btn-sm py-1 px-4 text-uppercase font-weight-bold" :href="status.url" style="font-weight: 500">View Post</a>
                            </div> -->
                        </div>
                        <div class="card-footer">
                            <a class="btn btn-primary btn-block font-weight-bold rounded-0" :href="status.url">View Post</a>
                        </div>
                    </div>
                </div>
                <div v-else>
                    <div class="border py-3 text-center font-weight-bold">No results found</div>
                </div>
            </div>
        </div>
        <div v-else class="col-12">
            <p class="text-center text-muted lead font-weight-bold">No results found</p>
        </div>
    </div>

</div>
</template>

<style type="text/css" scoped>
.result-card {
    text-decoration: none;
}
.result-card .media:hover {
    background: #EDF2F7;
}
@media (min-width: 1200px) {
    .container {
        max-width: 995px;
    }
}
</style>

<script type="text/javascript">
export default {
    props: ['query', 'profileId'],

    data() {
        return {
            loading: true,
            networkError: false,
            results: {
                hashtags: [],
                profiles: [],
                statuses: [],
                places: [],
            },
            filters: {
                hashtags: true,
                profiles: true,
                statuses: true
            },
            analysis: 'profile',
            showPlaces: false,
            placesCursor: 1,
            placesCache: [],
            placesSearchEnabled: false,
            searchVersion: 2
        }
    },
    beforeMount() {
        this.bootSearch();
    },
    mounted() {
        $('.search-bar input').val(this.query);
    },
    updated() {
        $('[data-toggle="tooltip"]').tooltip();
    },
    methods: {
        bootSearch() {
            let lexer = this.searchLexer();
            this.analysis = lexer;
            this.fetchSearchResults();
            axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
                window._sharedData.curUser = res.data;
                window.App.util.navatar();
            });
        },

        fetchSearchResults() {
            if(this.analysis == 'remote') {
                let term = this.query;
                let parsed = new URL(term);
                if(parsed.host === window.location.host) {
                    window.location.href = term;
                    return;
                }
            }
            this.searchContext(this.analysis);
        },

        searchLexer() {
            let q = this.query;

            if(q.startsWith('#')) {
                return 'hashtag';
            }

            if((q.match(/@/g) || []).length == 2) {
                return 'webfinger';
            }

            if(q.startsWith('@')) {
                return 'profile';
            }

            if(q.startsWith('https://')) {
                return 'remote';
            }

            return 'all';
        },

        buildUrl(type = 'hashtag', obj) {
            switch(type) {
                case 'hashtag':
                    return obj.url + '?src=search';
                break;

                case 'profile':
                    if(obj.entity.local == true) {
                        return obj.url;
                    }
                    return '/i/web/profile/_/' + obj.entity.id;
                break;

                default:
                    return obj.url + '?src=search';
                break;

            }
        },

        searchContext(type) {
            switch(type) {
                case 'all': 
                    axios.get('/api/search', {
                        params: {
                            'q': this.query,
                            'src': 'metro',
                            'v': this.searchVersion,
                            'scope': 'all'
                        }
                    }).then(res => {
                        let results = res.data;
                        this.results.hashtags = results.hashtags ? results.hashtags : [];
                        this.results.profiles = results.profiles ? results.profiles : [];
                        this.results.statuses = results.posts ? results.posts : [];
                        this.results.places   = results.places ? results.places : [];
                        this.placesCache = results.places;
                        this.results.placesPagination   = results.placesPagination ? results.placesPagination : [];
                        this.loading = false;
                    }).catch(err => {
                        this.loading = false;
                        this.networkError = true;
                    });
                break;

                case 'remote': 
                    axios.get('/api/search', {
                        params: {
                            'q': this.query,
                            'src': 'metro',
                            'v': this.searchVersion,
                            'scope': 'remote'
                        }
                    }).then(res => {
                        let results = res.data;
                        this.results.hashtags = results.hashtags ? results.hashtags : [];
                        this.results.profiles = results.profiles ? results.profiles : [];
                        this.results.statuses = results.posts ? results.posts : [];

                        if(this.results.profiles.length) {
                            this.analysis = 'profile';
                        }
                        if(this.results.statuses.length) {
                            this.analysis = 'remotePost';
                        }
                        this.loading = false;
                    }).catch(err => {
                        this.loading = false;
                        this.networkError = true;
                    });
                break;

                case 'hashtag':
                    axios.get('/api/search', {
                        params: {
                            'q': this.query.slice(1),
                            'src': 'metro',
                            'v': this.searchVersion,
                            'scope': 'hashtag'
                        }
                    }).then(res => {
                        let results = res.data;
                        this.results.hashtags = results.hashtags ? results.hashtags : [];
                        this.results.profiles = results.profiles ? results.profiles : [];
                        this.results.statuses = results.posts ? results.posts : [];
                        this.loading = false;
                    }).catch(err => {
                        this.loading = false;
                        this.networkError = true;
                    });
                break;

                case 'profile':
                    axios.get('/api/search', {
                        params: {
                            'q': this.query,
                            'src': 'metro',
                            'v': this.searchVersion,
                            'scope': 'profile'
                        }
                    }).then(res => {
                        let results = res.data;
                        this.results.hashtags = results.hashtags ? results.hashtags : [];
                        this.results.profiles = results.profiles ? results.profiles : [];
                        this.results.statuses = results.posts ? results.posts : [];
                        this.loading = false;
                    }).catch(err => {
                        this.loading = false;
                        this.networkError = true;
                    });
                break;

                case 'webfinger':
                    axios.get('/api/search', {
                        params: {
                            'q': this.query,
                            'src': 'metro',
                            'v': this.searchVersion,
                            'scope': 'webfinger'
                        }
                    }).then(res => {
                        let results = res.data;
                        this.results.hashtags = [];
                        this.results.profiles = results.profiles;
                        this.results.statuses = [];
                        this.loading = false;
                    }).catch(err => {
                        this.loading = false;
                        this.networkError = true;
                    });
                break;

                default:
                    this.loading = false;
                    this.networkError = true;
                break;
            }
        },

        placesPrevPage() {
            this.placesCursor--;
            if(this.placesCursor == 1) {
                this.results.places = this.placesCache.slice(0, 20);
                return;
            }
            let plc = this.placesCursor * 20;
            this.results.places = this.placesCache.slice(plc, 20);
            return;
        },

        placesNextPage() {
            this.placesCursor++;
            let plc = this.placesCursor * 20;
            if(this.placesCache.length > 20) {
                this.results.places = this.placesCache.slice(this.placesCursor == 1 ? 0 : plc, 20);
                return;
            } 
            axios.get('/api/search', {
                params: {
                    'q': this.query,
                    'src': 'metro',
                    'v': this.searchVersion,
                    'scope': 'all',
                    'page': this.placesCursor
                }
            }).then(res => {
                let results = res.data;
                this.results.places = results.places ? results.places : [];
                this.placesCache.push(...results.places);
                this.loading = false;
            }).catch(err => {
                this.loading = false;
                this.networkError = true;
            });

        },

        formatCount(num) {
            let count = window.App.util.format.count(num);
            return count;
        }
    }

}
</script>