Browse Source

Add AccountImport component

Daniel Supernault 2 years ago
parent
commit
828f369373

+ 606 - 0
resources/assets/components/AccountImport.vue

@@ -0,0 +1,606 @@
+<template>
+    <div class="h-100 pf-import">
+        <div v-if="!loaded" class="d-flex justify-content-center align-items-center h-100">
+            <b-spinner />
+        </div>
+        <template v-else>
+            <input type="file" name="file" class="d-none" ref="zipInput" @change="zipInputChanged" />
+            <template v-if="page === 1">
+                <div class="title">
+                    <h3 class="font-weight-bold">Import</h3>
+                </div>
+                <hr>
+                <section>
+                    <p class="lead">Account Import allows you to import your data from a supported service.</p>
+                </section>
+                <section class="mt-4">
+                    <ul class="list-group">
+                        <li class="list-group-item d-flex justify-content-between flex-column" style="gap:1rem">
+                            <div class="d-flex justify-content-between align-items-center" style="gap: 1rem;">
+                                <div>
+                                    <p class="font-weight-bold mb-1">Import from Instagram</p>
+                                    <p v-if="showDisabledWarning" class="small mb-0">This feature has been disabled by the administrators.</p>
+                                    <p v-else-if="showNotAllowedWarning" class="small mb-0">You have not been permitted to use this feature, or have reached the maximum limits. For more info, view the <a href="/site/kb/import" class="font-weight-bold">Import Help Center</a> page.</p>
+                                    <p v-else class="small mb-0">Upload the JSON export from Instagram in .zip format.<br />For more information click <a href="/site/kb/import">here</a>.</p>
+                                </div>
+                                <div v-if="!showDisabledWarning && !showNotAllowedWarning">
+                                    <button
+                                        v-if="step === 1 || invalidArchive"
+                                        type="button"
+                                        class="font-weight-bold btn btn-primary rounded-pill px-4 btn-lg"
+                                        @click="selectArchive()"
+                                        :disabled="showDisabledWarning">
+                                        Import
+                                    </button>
+
+                                    <template v-else-if="step === 2">
+                                        <div class="d-flex justify-content-center align-items-center flex-column">
+                                            <b-spinner v-if="showUploadLoader" small />
+                                            <button v-else type="button" class="font-weight-bold btn btn-outline-primary btn-sm btn-block" @click="reviewImports()">Review Imports</button>
+                                            <p v-if="zipName" class="small font-weight-bold mt-2 mb-0">{{ zipName }}</p>
+                                        </div>
+                                    </template>
+                                </div>
+                            </div>
+                        </li>
+                    </ul>
+                    <ul class="list-group mt-3">
+
+                        <li v-if="processingCount" class="list-group-item d-flex justify-content-between flex-column" style="gap:1rem">
+                            <div class="d-flex justify-content-between align-items-center">
+                                <div>
+                                    <p class="font-weight-bold mb-1">Processing Imported Posts</p>
+                                    <p class="small mb-0">These are posts that are in the process of being imported.</p>
+                                </div>
+                                <div>
+                                    <span class="btn btn-danger rounded-pill py-0 font-weight-bold" disabled>{{ processingCount }}</span>
+                                </div>
+                            </div>
+                        </li>
+
+                        <li v-if="finishedCount" class="list-group-item d-flex justify-content-between flex-column" style="gap:1rem">
+                            <div class="d-flex justify-content-between align-items-center">
+                                <div>
+                                    <p class="font-weight-bold mb-1">Imported Posts</p>
+                                    <p class="small mb-0">These are posts that have been successfully imported.</p>
+                                </div>
+                                <div>
+                                    <button
+                                        type="button"
+                                        class="font-weight-bold btn btn-primary btn-sm rounded-pill px-4 btn-block"
+                                        @click="handleReviewPosts()"
+                                        :disabled="!finishedCount">
+                                        Review {{ finishedCount }} Posts
+                                    </button>
+                                </div>
+                            </div>
+                        </li>
+                    </ul>
+
+                </section>
+            </template>
+
+            <template v-else-if="page === 2">
+                <div class="d-flex justify-content-between align-items-center">
+
+                    <div class="title">
+                        <h3 class="font-weight-bold">Import from Instagram</h3>
+                    </div>
+
+                    <button
+                        class="btn btn-primary font-weight-bold rounded-pill px-4"
+                        :class="{ disabled: !selectedMedia || !selectedMedia.length }"
+                        :disabled="!selectedMedia || !selectedMedia.length || importButtonLoading"
+                        @click="handleImport()"
+                        >
+                        <b-spinner v-if="importButtonLoading" small />
+                        <span v-else>Import</span>
+                    </button>
+                </div>
+                <hr>
+                <section>
+                    <div class="d-flex justify-content-between align-items-center mb-3">
+                        <div v-if="!selectedMedia || !selectedMedia.length">
+                            <p class="lead mb-0">Review posts you'd like to import.</p>
+                            <p class="small text-muted mb-0">Tap on posts to include them in your import.</p>
+                        </div>
+                        <p v-else class="lead mb-0"><span class="font-weight-bold">{{ selectedPostsCounter }}</span> posts selected for import</p>
+                    </div>
+                </section>
+                <section class="row mb-n5 media-selector" style="max-height: 600px;overflow-y: auto;">
+                    <div v-for="media in postMeta" class="col-12 col-md-4">
+                        <div
+                            class="square cursor-pointer"
+                            @click="toggleSelectedPost(media)">
+                            <div
+                                v-if="media.media[0].uri.endsWith('.mp4')"
+                                :class="{ selected: selectedMedia.indexOf(media.media[0].uri) != -1 }"
+                                class="info-overlay-text-label rounded">
+                                <h5 class="text-white m-auto font-weight-bold">
+                                    <span>
+                                        <span class="far fa-video fa-2x p-2 d-flex-inline"></span>
+                                    </span>
+                                </h5>
+                            </div>
+                            <div
+                                v-else
+                                class="square-content"
+                                :class="{ selected: selectedMedia.indexOf(media.media[0].uri) != -1 }"
+                                :style="{ borderRadius: '5px', backgroundImage: 'url(' + getFileNameUrl(media.media[0].uri) + ')'}">
+                            </div>
+                        </div>
+                        <div class="d-flex mt-1 justify-content-between align-items-center">
+                            <p class="small"><i class="far fa-clock"></i> {{ formatDate(media.media[0].creation_timestamp) }}</p>
+                            <p class="small font-weight-bold"><a href="#" @click.prevent="showDetailsModal(media)"><i class="far fa-info-circle"></i> Details</a></p>
+                        </div>
+                    </div>
+                </section>
+            </template>
+
+            <template v-else-if="page === 'reviewImports'">
+                <div class="d-flex justify-content-between align-items-center">
+
+                    <div class="title">
+                        <h3 class="font-weight-bold">Posts Imported from Instagram</h3>
+                    </div>
+                </div>
+                <hr>
+                <section class="row mb-n5 media-selector" style="max-height: 600px;overflow-y: auto;">
+                    <div v-for="media in importedPosts.data" class="col-12 col-md-4">
+                        <div
+                            class="square cursor-pointer">
+                            <div
+                                v-if="media.media_attachments[0].url.endsWith('.mp4')"
+                                class="info-overlay-text-label rounded">
+                                <h5 class="text-white m-auto font-weight-bold">
+                                    <span>
+                                        <span class="far fa-video fa-2x p-2 d-flex-inline"></span>
+                                    </span>
+                                </h5>
+                            </div>
+                            <div
+                                v-else
+                                class="square-content"
+                                :style="{ borderRadius: '5px', backgroundImage: 'url(' + media.media_attachments[0].url + ')'}">
+                            </div>
+                        </div>
+                        <div class="d-flex mt-1 justify-content-between align-items-center">
+                            <p class="small"><i class="far fa-clock"></i> {{ formatDate(media.created_at, false) }}</p>
+                            <p class="small font-weight-bold"><a :href="media.url"><i class="far fa-info-circle"></i> View</a></p>
+                        </div>
+                    </div>
+
+                    <div class="col-12 my-3">
+                        <button
+                            v-if="importedPosts.meta && importedPosts.meta.next_cursor"
+                            class="btn btn-primary btn-block font-weight-bold"
+                            @click="loadMorePosts()">
+                            Load more
+                        </button>
+                    </div>
+                </section>
+            </template>
+        </template>
+
+        <b-modal
+            id="detailsModal"
+            title="Post Details"
+            v-model="detailsModalShow"
+            :ok-only="true"
+            ok-title="Close"
+            centered>
+            <div class="">
+                <div v-for="(media, idx) in modalData.media" class="mb-3">
+                    <div class="list-group">
+                        <div class="list-group-item d-flex justify-content-between align-items-center">
+                            <p class="text-center font-weight-bold mb-0">Media #{{idx + 1}}</p>
+                            <img :src="getFileNameUrl(media.uri)" width="30" height="30" style="object-fit: cover; border-radius: 5px;">
+                        </div>
+                        <div class="list-group-item">
+                            <p class="small text-muted">Caption</p>
+                            <p class="mb-0 small read-more" style="font-size: 12px;overflow-y: hidden;">{{ media.title ? media.title : modalData.title }}</p>
+                        </div>
+                        <div class="list-group-item">
+                            <div class="d-flex justify-content-between align-items-center">
+                                <p class="small mb-0 text-muted">Timestamp</p>
+                                <p class="font-weight-bold mb-0">{{ formatDate(media.creation_timestamp) }}</p>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </b-modal>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import * as zip from "@zip.js/zip.js";
+
+    export default {
+        data() {
+            return {
+                page: 1,
+                step: 1,
+                toggleLimit: 100,
+                config: {},
+                showDisabledWarning: false,
+                showNotAllowedWarning: false,
+                invalidArchive: false,
+                loaded: false,
+                existing: [],
+                zipName: undefined,
+                zipFiles: [],
+                postMeta: [],
+                imageCache: [],
+                includeArchives: false,
+                selectedMedia: [],
+                selectedPostsCounter: 0,
+                detailsModalShow: false,
+                modalData: {},
+                importedPosts: [],
+                finishedCount: undefined,
+                processingCount: undefined,
+                showUploadLoader: false,
+                importButtonLoading: false,
+            }
+        },
+
+        mounted() {
+            this.fetchConfig();
+        },
+
+        methods: {
+            fetchConfig() {
+                axios.get('/api/local/import/ig/config')
+                .then(res => {
+                    this.config = res.data;
+
+                    if(res.data.enabled == false) {
+                        this.showDisabledWarning = true;
+                        this.loaded = true;
+                    } else if(res.data.allowed == false) {
+                        this.showNotAllowedWarning = true;
+                        this.loaded = true;
+                    } else {
+                        this.fetchExisting();
+                    }
+                })
+            },
+
+            fetchExisting() {
+                axios.post('/api/local/import/ig/existing')
+                .then(res => {
+                    this.existing = res.data;
+                })
+                .finally(() => {
+                    this.fetchProcessing();
+                })
+            },
+
+            fetchProcessing() {
+                axios.post('/api/local/import/ig/processing')
+                .then(res => {
+                    this.processingCount = res.data.processing_count;
+                    this.finishedCount = res.data.finished_count;
+                })
+                .finally(() => {
+                    this.loaded = true;
+                })
+            },
+
+            selectArchive() {
+                event.currentTarget.blur();
+                swal({
+                    title: 'Upload Archive',
+                    icon: 'success',
+                    text: 'The .zip archive is probably named something like username_20230606.zip, and was downloaded from the Instagram.com website.',
+                    buttons: {
+                        cancel: "Cancel",
+                        danger: {
+                            text: "Upload zip archive",
+                            value: "upload"
+                        }
+                    }
+                })
+                .then(res => {
+                    this.$refs.zipInput.click();
+                })
+            },
+
+            zipInputChanged(event) {
+                this.step = 2;
+                this.zipName = event.target.files[0].name;
+                this.showUploadLoader = true;
+                setTimeout(() => {
+                    this.reviewImports();
+                }, 1000);
+                setTimeout(() => {
+                    this.showUploadLoader = false;
+                }, 3000);
+            },
+
+            reviewImports() {
+                this.invalidArchive = false;
+                this.checkZip();
+            },
+
+            model(file, options = {}) {
+                return (new zip.ZipReader(new zip.BlobReader(file))).getEntries(options);
+            },
+
+            formatDate(ts, unixt = true) {
+                let date = unixt ? new Date(ts * 1000) : new Date(ts);
+                return date.toLocaleDateString()
+            },
+
+            getFileNameUrl(filename) {
+                return this.imageCache.filter(e => e.filename === filename).map(e => e.blob);
+            },
+
+            showDetailsModal(entry) {
+                this.modalData = entry;
+                this.detailsModalShow = true;
+                setTimeout(() => {
+                    pixelfed.readmore();
+                }, 500);
+            },
+
+            filterPostMeta(media) {
+                let json = JSON.parse(media);
+                let res = json.filter(j => {
+                    let ids = j.media.map(m => m.uri).filter(m => {
+                        if(this.config.allow_video_posts == true) {
+                            return m.endsWith('.png') || m.endsWith('.jpg') || m.endsWith('.mp4');
+                        } else {
+                            return m.endsWith('.png') || m.endsWith('.jpg');
+                        }
+                    });
+                    return ids.length;
+                }).filter(j => {
+                    let ids = j.media.map(m => m.uri);
+                    return !this.existing.includes(ids[0]);
+                })
+                this.postMeta = res;
+                return res;
+            },
+
+            async checkZip() {
+                let file = this.$refs.zipInput.files[0];
+                let entries = await this.model(file);
+                if (entries && entries.length) {
+                    let files = await entries.filter(e => e.filename === 'content/posts_1.json');
+
+                    if(!files || !files.length) {
+                        this.contactModal(
+                            'Invalid import archive',
+                            "The .zip archive you uploaded is corrupted, or is invalid. We cannot process your import at this time.\n\nIf this issue persists, please contact an administrator.",
+                            'error'
+                        )
+                        this.invalidArchive = true;
+                        return;
+                    } else {
+                        this.readZip();
+                    }
+                }
+            },
+
+            async readZip() {
+                let file = this.$refs.zipInput.files[0];
+                let entries = await this.model(file);
+                if (entries && entries.length) {
+                    this.zipFiles = entries;
+                    let media = await entries.filter(e => e.filename === 'content/posts_1.json')[0].getData(new zip.TextWriter());
+                    this.filterPostMeta(media);
+
+                    let imgs = await Promise.all(entries.filter(entry => {
+                        return entry.filename.startsWith('media/posts/') && (entry.filename.endsWith('.png') || entry.filename.endsWith('.jpg') || entry.filename.endsWith('.mp4'));
+                    })
+                    .map(async entry => {
+                        if(
+                            entry.filename.startsWith('media/posts/') &&
+                            (
+                                entry.filename.endsWith('.png') ||
+                                entry.filename.endsWith('.jpg') ||
+                                entry.filename.endsWith('.mp4')
+                            )
+                        ) {
+                            let types = {
+                                'png': 'image/png',
+                                'jpg': 'image/jpeg',
+                                'jpeg': 'image/jpeg',
+                                'mp4': 'video/mp4'
+                            }
+                            let type = types[entry.filename.split('/').pop().split('.').pop()];
+                            let blob = await entry.getData(new zip.BlobWriter(type));
+                            let url = URL.createObjectURL(blob);
+                            return {
+                                filename: entry.filename,
+                                blob: url,
+                                file: blob
+                            }
+                        } else {
+                            return;
+                        }
+                    }));
+                    this.imageCache = imgs.flat(2);
+                }
+                setTimeout(() => {
+                    this.page = 2;
+                }, 500);
+            },
+
+            toggleLimitReached() {
+                this.contactModal(
+                    'Limit reached',
+                    "You can only import " + this.toggleLimit + " posts at a time.\nYou can import more posts after you finish importing these posts.",
+                    'error'
+                )
+            },
+
+            toggleSelectedPost(media) {
+                let filename;
+                let self = this;
+                if(media.media.length === 1) {
+                    filename = media.media[0].uri
+                    if(this.selectedMedia.indexOf(filename) == -1) {
+                        if(this.selectedPostsCounter >= this.toggleLimit) {
+                            this.toggleLimitReached();
+                            return;
+                        }
+                        this.selectedMedia.push(filename);
+                        this.selectedPostsCounter++;
+                    } else {
+                        let idx = this.selectedMedia.indexOf(filename);
+                        this.selectedMedia.splice(idx, 1);
+                        this.selectedPostsCounter--;
+                    }
+                } else {
+                    filename = media.media[0].uri
+                    if(this.selectedMedia.indexOf(filename) == -1) {
+                        if(this.selectedPostsCounter >= this.toggleLimit) {
+                            this.toggleLimitReached();
+                            return;
+                        }
+                        this.selectedPostsCounter++;
+                    } else {
+                        this.selectedPostsCounter--;
+                    }
+                    media.media.forEach(function(m) {
+                        filename = m.uri
+                        if(self.selectedMedia.indexOf(filename) == -1) {
+                            self.selectedMedia.push(filename);
+                        } else {
+                            let idx = self.selectedMedia.indexOf(filename);
+                            self.selectedMedia.splice(idx, 1);
+                        }
+                    })
+                }
+            },
+
+            sliceIntoChunks(arr, chunkSize) {
+                const res = [];
+                for (let i = 0; i < arr.length; i += chunkSize) {
+                    const chunk = arr.slice(i, i + chunkSize);
+                    res.push(chunk);
+                }
+                return res;
+            },
+
+            handleImport() {
+                swal('Importing...', "Please wait while we upload your imported posts.\n Keep this page open and do not navigate away.", 'success');
+                this.importButtonLoading = true;
+                let ic = this.imageCache.filter(e => {
+                    return this.selectedMedia.indexOf(e.filename) != -1;
+                })
+                let chunks = this.sliceIntoChunks(ic, 10);
+                chunks.forEach(c => {
+                    let formData = new FormData();
+                    c.map((e, idx) => {
+                        let file = new File([e.file], e.filename);
+                        formData.append('file['+ idx +']', file, e.filename.split('/').pop());
+                    })
+                    axios.post(
+                        '/api/local/import/ig/media',
+                        formData,
+                        {
+                            headers: {
+                                'Content-Type': `multipart/form-data`,
+                            },
+                        }
+                    )
+                    .catch(err => {
+                        this.contactModal(
+                            'Error',
+                            err.response.data.message,
+                            'error'
+                        )
+                    });
+                })
+                axios.post('/api/local/import/ig', {
+                    files: this.postMeta.filter(e => this.selectedMedia.includes(e.media[0].uri)).map(e => {
+                        if(e.hasOwnProperty('title')) {
+                            return {
+                                title: e.title,
+                                'creation_timestamp': e.creation_timestamp,
+                                uri: e.uri,
+                                media: e.media
+                            }
+                        } else {
+                            return {
+                                title: null,
+                                'creation_timestamp': null,
+                                uri: null,
+                                media: e.media
+                            }
+                        }
+                    })
+                }).then(res => {
+                    if(res) {
+                        setTimeout(() => {
+                            window.location.reload()
+                        }, 5000);
+                    }
+                }).catch(err => {
+                    this.contactModal(
+                        'Error',
+                        err.response.data.error,
+                        'error'
+                    )
+                })
+            },
+
+            handleReviewPosts() {
+                this.page = 'reviewImports';
+
+                axios.post('/api/local/import/ig/posts')
+                .then(res => {
+                    this.importedPosts = res.data;
+                })
+            },
+
+            loadMorePosts() {
+                event.currentTarget.blur();
+
+                axios.post('/api/local/import/ig/posts', {
+                    cursor: this.importedPosts.meta.next_cursor
+                })
+                .then(res => {
+                    let data = res.data;
+                    data.data = [...this.importedPosts.data, ...res.data.data];
+                    this.importedPosts = data;
+                })
+            },
+
+            contactModal(title = 'Error', text, icon, closeButton = 'Close') {
+                swal({
+                    title: title,
+                    text: text,
+                    icon: icon,
+                    dangerMode: true,
+                    buttons: {
+                        ok: closeButton,
+                        danger: {
+                            text: 'Contact Support',
+                            value: 'contact'
+                        }
+                    }
+                })
+                .then(res => {
+                    if(res === 'contact') {
+                        window.location.href = '/site/contact'
+                    }
+                });
+            }
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+    .pf-import {
+        .media-selector {
+            .selected {
+                border: 5px solid red;
+            }
+        }
+    }
+</style>

+ 4 - 0
resources/assets/js/account-import.js

@@ -0,0 +1,4 @@
+Vue.component(
+    'account-import',
+    require('./../components/AccountImport.vue').default
+);

+ 1 - 0
webpack.mix.js

@@ -34,6 +34,7 @@ mix.js('resources/assets/js/app.js', 'public/js')
 .js('resources/assets/js/spa.js', 'public/js')
 .js('resources/assets/js/stories.js', 'public/js')
 .js('resources/assets/js/portfolio.js', 'public/js')
+.js('resources/assets/js/account-import.js', 'public/js')
 .js('resources/assets/js/admin_invite.js', 'public/js')
 .js('resources/assets/js/landing.js', 'public/js')
 .vue({ version: 2 });