pixelfed/pixelfed

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

Summary

Maintainability
Test Coverage
<template>
    <div class="portfolio-settings px-3">
        <div v-if="loading" class="d-flex justify-content-center align-items-center py-5">
            <b-spinner variant="primary" />
        </div>
        <div v-else class="row justify-content-center mb-5 pb-5">
            <div class="col-12 col-md-8 bg-dark py-2 rounded">
                <ul class="nav nav-pills nav-fill">
                    <li v-for="(tab, index) in tabs" class="nav-item" :class="{ disabled: index !== 0 && !settings.active}">
                        <span v-if="index !== 0 && !settings.active" class="nav-link">{{ tab }}</span>
                        <a v-else class="nav-link" :class="{ active: tab === tabIndex }" href="#" @click.prevent="toggleTab(tab)">{{ tab }}</a>
                    </li>
                </ul>
            </div>

            <transition name="slide-fade">
                <div v-if="tabIndex === 'Configure'" class="col-12 col-md-8 bg-dark mt-3 py-2 rounded" key="0">
                    <div v-if="!user.statuses_count" class="alert alert-danger">
                        <p class="mb-0 small font-weight-bold">You don't have any public posts, once you share public posts you can enable your portfolio.</p>
                    </div>

                    <div class="d-flex justify-content-between align-items-center py-2">
                        <div class="setting-label">
                            <p class="lead mb-0">Portfolio Enabled</p>
                            <p class="small mb-0 text-muted">You must enable your portfolio before you or anyone can view it.</p>
                        </div>

                        <div class="setting-switch mt-n1">
                            <b-form-checkbox v-model="settings.active" name="check-button" size="lg" switch :disabled="!user.statuses_count" />
                        </div>
                    </div>

                    <hr>

                    <div class="d-flex justify-content-between align-items-center py-2">
                        <div class="setting-label" style="max-width: 50%;">
                            <p class="mb-0">Portfolio Source</p>
                            <p class="small mb-0 text-muted">Choose how you want to populate your portfolio, select Most Recent posts to automatically update your portfolio with recent posts or Curated Posts to select specific posts for your portfolio.</p>
                        </div>
                        <div class="ml-3">
                            <b-form-select v-model="settings.profile_source" :options="profileSourceOptions" :disabled="!user.statuses_count" />
                        </div>
                    </div>
                </div>

                <div v-else-if="tabIndex === 'Curate'" class="col-12 col-md-8 mt-3 py-2 px-0" key="1">
                    <div v-if="!recentPostsLoaded" class="d-flex align-items-center justify-content-center py-5 my-5">
                        <div class="text-center">
                            <div class="spinner-border" role="status">
                                <span class="sr-only">Loading...</span>
                            </div>
                            <p class="text-muted">Loading recent posts...</p>
                        </div>
                    </div>

                    <template v-else>
                        <div class="mt-n2 mb-4">
                            <p class="text-muted small">Select up to 100 photos from your 100 most recent posts. You can only select public photo posts, videos are not supported at this time.</p>

                            <div class="d-flex align-items-center justify-content-between">
                                <p class="font-weight-bold mb-0">Selected {{ selectedRecentPosts.length }}/100</p>
                                <div>
                                    <button
                                        class="btn btn-link font-weight-bold mr-3 text-decoration-none"
                                        :disabled="!selectedRecentPosts.length"
                                        @click="clearSelected">
                                        Clear selected
                                    </button>

                                    <button
                                        class="btn btn-primary py-0 font-weight-bold"
                                        style="width: 150px;"
                                        :disabled="!canSaveCurated"
                                        @click="saveCurated()">
                                        <template v-if="!isSavingCurated">Save</template>
                                        <b-spinner v-else small />
                                    </button>
                                </div>
                            </div>
                        </div>

                        <div class="d-flex justify-content-between align-items-center">
                            <span @click="recentPostsPrev">
                                <i :class="prevClass" />
                            </span>

                            <div class="row flex-grow-1 mx-2">
                                <div v-for="(post, index) in recentPosts.slice(rpStart, rpStart + 9)" class="col-12 col-md-4 mb-1 p-1">
                                        <div class="square user-select-none" @click.prevent="toggleRecentPost(post.id)">
                                            <transition name="fade">
                                                <img
                                                    :key="post.id"
                                                    :src="getPreviewUrl(post)"
                                                    width="100%"
                                                    height="300"
                                                    style="overflow: hidden;object-fit: cover;"
                                                    :draggable="false"
                                                    loading="lazy"
                                                    onerror="this.src='/storage/no-preview.png';this.onerror=null;"
                                                    class="square-content pr-1">
                                            </transition>

                                            <div v-if="selectedRecentPosts.indexOf(post.id) !== -1" style="position: absolute;right: -5px;bottom:-5px;">
                                                <div class="selected-badge">{{ selectedRecentPosts.indexOf(post.id) + 1 }}</div>
                                            </div>
                                        </div>
                                </div>
                            </div>

                            <span @click="recentPostsNext()">
                                <i :class="nextClass" />
                            </span>
                        </div>
                    </template>
                </div>

                <div v-else-if="tabIndex === 'Customize'" class="col-12 mt-3 py-2" key="2">
                    <div class="row">
                        <div class="col-12 col-md-6">
                            <div v-for="setting in customizeSettings" class="card bg-dark mb-5">
                                <div class="card-header">{{ setting.title }}</div>
                                <div class="list-group bg-dark">
                                    <div v-for="item in setting.items" class="list-group-item">
                                        <div class="d-flex justify-content-between align-items-center py-2">
                                            <div class="setting-label">
                                                <p class="mb-0">{{ item.label }}</p>
                                                <p v-if="item.description" class="small text-muted mb-0">{{ item.description }}</p>
                                            </div>

                                            <div class="setting-switch mt-n1">
                                                <b-form-checkbox
                                                    v-model="settings[item.model]"
                                                    name="check-button"
                                                    size="lg"
                                                    switch
                                                    :disabled="item.requiredWithTrue && !settings[item.requiredWithTrue]" />
                                            </div>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>

                        <div class="col-12 col-md-6">
                            <div class="card bg-dark mb-5">
                                <div class="card-header">Portfolio</div>
                                <div class="list-group bg-dark">
                                    <div class="list-group-item">
                                        <div class="d-flex justify-content-between align-items-center py-2">
                                            <div class="setting-label">
                                                <p class="mb-0">Layout</p>
                                            </div>

                                            <div>
                                                <b-form-select v-model="settings.profile_layout" :options="profileLayoutOptions" />
                                            </div>
                                        </div>
                                    </div>

                                    <div v-if="settings.profile_source === 'custom'" class="list-group-item">
                                        <div class="d-flex justify-content-between align-items-center py-2">
                                            <div class="setting-label">
                                                <p class="mb-0">Order</p>
                                            </div>

                                            <div>
                                                <b-form-select
                                                    v-model="settings.feed_order"
                                                    :options="profileLayoutFeedOrder" />
                                            </div>
                                        </div>
                                    </div>

                                    <div class="list-group-item">
                                        <div class="d-flex justify-content-between align-items-center py-2">
                                            <div class="setting-label">
                                                <p class="mb-0">Color Scheme</p>
                                            </div>

                                            <div>
                                                <b-form-select
                                                    v-model="settings.color_scheme"
                                                    :options="profileLayoutColorSchemeOptions"
                                                    :disabled="settings.color_scheme === 'custom'"
                                                    @change="updateColorScheme" />
                                            </div>
                                        </div>
                                    </div>

                                    <div class="list-group-item">
                                        <div class="d-flex justify-content-between align-items-center py-2">
                                            <div class="setting-label">
                                                <p class="mb-0">Background Color</p>
                                            </div>

                                            <b-col sm="2">
                                                <b-form-input
                                                    v-model="settings.background_color"
                                                    debounce="1000"
                                                    type="color"
                                                    @change="updateBackgroundColor" />

                                                <b-button
                                                    v-if="!['#000000', null].includes(settings.background_color)"
                                                    variant="link"
                                                    @click="resetBackgroundColor">
                                                    Reset
                                                </b-button>
                                            </b-col>
                                        </div>
                                    </div>

                                    <div class="list-group-item">
                                        <div class="d-flex justify-content-between align-items-center py-2">
                                            <div class="setting-label">
                                                <p class="mb-0">Text Color</p>
                                            </div>

                                            <b-col sm="2">
                                                <b-form-input
                                                    v-model="settings.text_color"
                                                    debounce="1000"
                                                    type="color"
                                                    @change="updateTextColor" />

                                                <b-button
                                                    v-if="!['#d4d4d8', null].includes(settings.text_color)"
                                                    variant="link"
                                                    @click="resetTextColor">
                                                    Reset
                                                </b-button>
                                            </b-col>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>

                <div v-else-if="tabIndex === 'Share'" class="col-12 col-md-8 bg-dark mt-3 py-2 rounded" key="3">
                    <div class="py-2">
                        <p class="text-muted">Portfolio URL</p>
                        <p class="lead mb-0"><a :href="settings.url">{{ settings.url }}</a></p>
                    </div>
                </div>
            </transition>
        </div>
    </div>
</template>

<script type="text/javascript">
    export default {
        data() {
            return {
                loading: true,
                tabIndex: "Configure",
                tabs: [
                    "Configure",
                    "Customize",
                    "View Portfolio"
                ],
                user: undefined,
                settings: undefined,
                recentPostsLoaded: false,
                rpStart: 0,
                recentPosts: [],
                recentPostsPage: undefined,
                selectedRecentPosts: [],
                isSavingCurated: false,
                canSaveCurated: false,
                customizeSettings: [],
                skipWatch: false,
                profileSourceOptions: [
                    { value: null, text: 'Please select an option', disabled: true },
                    { value: 'recent', text: 'Most recent posts' },
                ],
                profileLayoutOptions: [
                    { value: null, text: 'Please select an option', disabled: true },
                    { value: 'grid', text: 'Grid' },
                    { value: 'masonry', text: 'Masonry' },
                    { value: 'album', text: 'Album' },
                ],
                profileLayoutColorSchemeOptions: [
                    { value: null, text: 'Please select an option', disabled: true },
                    { value: 'light', text: 'Light mode' },
                    { value: 'dark', text: 'Dark mode' },
                    { value: 'custom', text: 'Custom color scheme', disabled: true },
                ],
                profileLayoutFeedOrder: [
                    { value: 'oldest', text: 'Oldest first' },
                    { value: 'recent', text: 'Recent first' }
                ]
            }
        },

        computed: {
            prevClass() {
                return this.rpStart === 0 ?
                    "fa fa-arrow-circle-left fa-3x text-dark" :
                    "fa fa-arrow-circle-left fa-3x text-muted cursor-pointer";
            },

            nextClass() {
                return this.rpStart > (this.recentPosts.length - 9) ?
                    "fa fa-arrow-circle-right fa-3x text-dark" :
                    "fa fa-arrow-circle-right fa-3x text-muted cursor-pointer";
            },
        },

        watch: {
            settings: {
                deep: true,
                immediate: true,
                handler: function(o, n) {
                    if(this.loading || this.skipWatch) {
                        return;
                    }
                    if(!n.show_timestamp) {
                        this.settings.show_link = false;
                    }
                    this.updateSettings();
                }
            }
        },

        mounted() {
            this.fetchUser();
        },

        methods: {
            fetchUser() {
                axios.get('/api/v1/accounts/verify_credentials')
                .then(res => {
                    this.user = res.data;

                    if(res.data.statuses_count > 0) {
                        this.profileSourceOptions = [
                            { value: null, text: 'Please select an option', disabled: true },
                            { value: 'recent', text: 'Most recent posts' },
                            { value: 'custom', text: 'Curated posts' },
                        ];
                    } else {
                        setTimeout(() => {
                            this.settings.active = false;
                            this.settings.profile_source = 'recent';
                            this.tabIndex = 'Configure';
                        }, 1000);
                    }
                })

                axios.post(this.apiPath('/api/portfolio/self/settings.json'))
                .then(res => {
                    this.settings = res.data;
                    this.updateTabs();
                    if(res.data.metadata && res.data.metadata.posts) {
                        this.selectedRecentPosts = res.data.metadata.posts;
                    }

                    if(res.data.color_scheme != 'dark') {
                        if(res.data.color_scheme === 'light') {
                            this.updateBackgroundColor('#ffffff');
                        } else {
                            if(res.data.hasOwnProperty('background_color')) {
                                this.updateBackgroundColor(res.data.background_color);
                            }

                            if(res.data.hasOwnProperty('text_color')) {
                                this.updateTextColor(res.data.text_color);
                            }
                        }
                    }
                })
                .then(() => {
                    this.initCustomizeSettings();
                })
                .then(() => {
                    const url = new URL(window.location);
                    if(url.searchParams.has('tab')) {
                        let tab = url.searchParams.get('tab');
                        let tabs = this.settings.profile_source === 'custom' ?
                        ['curate', 'customize', 'share'] :
                        ['customize', 'share'];
                        if(tabs.indexOf(tab) !== -1) {
                            this.toggleTab(tab.slice(0, 1).toUpperCase() + tab.slice(1));
                        }
                    }
                })
                .then(() => {
                    setTimeout(() => {
                        this.loading = false;
                    }, 500);
                })
            },

            apiPath(path) {
                return path;
            },

            toggleTab(idx) {
                if(idx === 'Curate' && !this.recentPostsLoaded) {
                    this.loadRecentPosts();
                }
                this.tabIndex = idx;
                this.rpStart = 0;
                if(idx == 'Configure') {
                    const url = new URL(window.location);
                    url.searchParams.delete('tab');
                    window.history.pushState({}, '', url);
                } else if (idx == 'View Portfolio') {
                    this.tabIndex = 'Configure';
                    window.location.href = `https://${window._portfolio.domain}${window._portfolio.path}/${this.user.username}`;
                    return;
                } else {
                    const url = new URL(window.location);
                    url.searchParams.set('tab', idx.toLowerCase());
                    window.history.pushState({}, '', url);
                }
            },

            updateTabs() {
                if(this.settings.profile_source === 'custom') {
                    this.tabs = [
                        "Configure",
                        "Curate",
                        "Customize",
                        "View Portfolio"
                    ];
                } else {
                    this.tabs = [
                        "Configure",
                        "Customize",
                        "View Portfolio"
                    ];
                }
            },

            updateSettings(silent = false) {
                if(this.skipWatch) {
                    return;
                }

                axios.post(this.apiPath('/api/portfolio/self/update-settings.json'), this.settings)
                .then(res => {
                    this.updateTabs();
                    if(!silent) {
                        this.$bvToast.toast(`Your settings have been successfully updated!`, {
                            variant: 'dark',
                            title: 'Settings Updated',
                            autoHideDelay: 2000,
                            appendToast: false
                        })
                    }
                })
            },

            loadRecentPosts() {
                axios.get('/api/v1/accounts/' + this.user.id + '/statuses?only_media=1&media_types=photo&limit=100&_pe=1')
                .then(res => {
                    if(res.data.length) {
                        this.recentPosts = res.data.filter(p => ['photo', 'photo:album'].includes(p.pf_type) && p.visibility === "public");
                    }
                })
                .then(() => {
                    setTimeout(() => {
                        this.recentPostsLoaded = true;
                    }, 500);
                })
            },

            toggleRecentPost(id) {
                if(this.selectedRecentPosts.indexOf(id) == -1) {
                    if(this.selectedRecentPosts.length === 100) {
                        return;
                    }
                    this.selectedRecentPosts.push(id);
                } else {
                    this.selectedRecentPosts = this.selectedRecentPosts.filter(i => i !== id);
                }
                this.canSaveCurated = true;
            },

            recentPostsPrev() {
                if(this.rpStart === 0) {
                    return;
                }
                this.rpStart = this.rpStart - 9;
            },

            recentPostsNext() {
                if(this.rpStart > (this.recentPosts.length - 9)) {
                    return;
                }
                this.rpStart = this.rpStart + 9;
            },

            clearSelected() {
                this.selectedRecentPosts = [];
            },

            saveCurated() {
                this.isSavingCurated = true;
                event.currentTarget?.blur();

                axios.post('/api/portfolio/self/curated.json', {
                    ids: this.selectedRecentPosts
                })
                .then(res => {
                    this.isSavingCurated = false;
                    this.$bvToast.toast(`Your curated posts have been updated!`, {
                        variant: 'dark',
                        title: 'Portfolio Updated',
                        autoHideDelay: 2000,
                        appendToast: false
                    })
                })
                .catch(err => {
                    this.isSavingCurated = false;
                    this.$bvToast.toast(`An error occured while attempting to update your portfolio, please try again later and contact an admin if this problem persists.`, {
                        variant: 'dark',
                        title: 'Error',
                        autoHideDelay: 2000,
                        appendToast: false
                    })
                })
            },

            initCustomizeSettings() {
                this.customizeSettings = [
                    {
                        title: "Post Settings",
                        items: [
                            {
                                label: "Show Captions",
                                model: "show_captions"
                            },
                            {
                                label: "Show License",
                                model: "show_license"
                            },
                            {
                                label: "Show Location",
                                model: "show_location"
                            },
                            {
                                label: "Show Timestamp",
                                model: "show_timestamp"
                            },
                            {
                                label: "Link to Post",
                                description: "Add link to timestamp to view the original post url, requires show timestamp to be enabled",
                                model: "show_link",
                                requiredWithTrue: "show_timestamp"
                            }
                        ]
                    },

                    {
                        title: "Profile Settings",
                        items: [
                            {
                                label: "Show Avatar",
                                model: "show_avatar"
                            },
                            {
                                label: "Show Bio",
                                model: "show_bio"
                            },
                            {
                                label: "Show View Profile Button",
                                model: "show_profile_button"
                            },
                            {
                                label: "Enable RSS Feed",
                                description: "Enable your RSS feed with the 10 most recent portfolio items",
                                model: "rss_enabled"
                            },
                            {
                                label: "Show RSS Feed Button",
                                model: "show_rss_button",
                                requiredWithTrue: "rss_enabled"
                            },
                        ]
                    },
                ]

            },

            updateBackgroundColor(e) {
                this.skipWatch = true;
                let rs = document.querySelector(':root');
                rs.style.setProperty('--body-bg', e);

                if(e !== '#000000' && e !== '#ffffff') {
                    this.settings.color_scheme = 'custom';
                }

                this.$nextTick(() => {
                    this.skipWatch = false;
                });
            },

            updateTextColor(e) {
                this.skipWatch = true;
                let rs = document.querySelector(':root');
                rs.style.setProperty('--text-color', e);

                if(e !== '#d4d4d8') {
                    this.settings.color_scheme = 'custom';
                }

                this.$nextTick(() => {
                    this.skipWatch = false;
                });
            },

            resetBackgroundColor() {
                this.skipWatch = true;

                this.$nextTick(() => {
                    this.updateBackgroundColor('#000000');
                    this.settings.color_scheme = 'dark';
                    this.settings.background_color = '#000000';
                    this.updateSettings(true);

                    setTimeout(() => {
                        this.skipWatch = false;
                    }, 1000);
                });

            },

            resetTextColor() {
                this.skipWatch = true;

                this.$nextTick(() => {
                    this.updateTextColor('#d4d4d8');
                    this.settings.color_scheme = 'dark';
                    this.settings.text_color = '#d4d4d8';
                    this.updateSettings(true);

                    setTimeout(() => {
                        this.skipWatch = false;
                    }, 1000);
                });
            },

            updateColorScheme(e) {
                if(e === 'light') {
                    this.updateBackgroundColor('#ffffff');
                }

                if(e === 'dark') {
                    this.updateBackgroundColor('#000000');
                }
            },

            getPreviewUrl(post) {
                let media = post.media_attachments[0];
                if(!media) { return '/storage/no-preview.png'; }

                if(media.preview_url && !media.preview_url.endsWith('/no-preview.png')) {
                    return media.preview_url;
                }

                return media.url;
            }
        }
    }
</script>