Jelajahi Sumber

Add Portfolio feature

Daniel Supernault 2 tahun lalu
induk
melakukan
356a882dbc

+ 318 - 0
app/Http/Controllers/PortfolioController.php

@@ -0,0 +1,318 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Models\Portfolio;
+use Cache;
+use DB;
+use App\Status;
+use App\User;
+use App\Services\AccountService;
+use App\Services\StatusService;
+
+class PortfolioController extends Controller
+{
+    public function index(Request $request)
+    {
+        return view('portfolio.index');
+    }
+
+    public function show(Request $request, $username)
+    {
+        $user = User::whereUsername($username)->first();
+
+        if(!$user) {
+            return view('portfolio.404');
+        }
+
+        $portfolio = Portfolio::whereUserId($user->id)->firstOrFail();
+        $user = AccountService::get($user->profile_id);
+
+        if($user['locked']) {
+            return view('portfolio.404');
+        }
+
+        if($portfolio->active != true) {
+            if(!$request->user()) {
+                return view('portfolio.404');
+            }
+
+            if($request->user()->profile_id == $user['id']) {
+                return redirect(config('portfolio.path') . '/settings');
+            }
+
+            return view('portfolio.404');
+        }
+
+        return view('portfolio.show', compact('user', 'portfolio'));
+    }
+
+    public function showPost(Request $request, $username, $id)
+    {
+        $authed = $request->user();
+        $post = StatusService::get($id);
+
+        if(!$post) {
+            return view('portfolio.404');
+        }
+
+        $user = AccountService::get($post['account']['id']);
+        $portfolio = Portfolio::whereProfileId($user['id'])->first();
+
+        if($user['locked'] || $portfolio->active != true) {
+            return view('portfolio.404');
+        }
+
+        if(!$post || $post['visibility'] != 'public' || $post['pf_type'] != 'photo' || $user['id'] != $post['account']['id']) {
+            return view('portfolio.404');
+        }
+
+        return view('portfolio.show_post', compact('user', 'post', 'authed'));
+    }
+
+    public function myRedirect(Request $request)
+    {
+        abort_if(!$request->user(), 404);
+
+        $user = $request->user();
+
+        if(Portfolio::whereProfileId($user->profile_id)->exists() === false) {
+            $portfolio = new Portfolio;
+            $portfolio->profile_id = $user->profile_id;
+            $portfolio->user_id = $user->id;
+            $portfolio->active = false;
+            $portfolio->save();
+        }
+
+        $domain = config('portfolio.domain');
+        $path = config('portfolio.path');
+        $url = 'https://' . $domain . $path;
+
+        return redirect($url);
+    }
+
+    public function settings(Request $request)
+    {
+        if(!$request->user()) {
+            return redirect(route('home'));
+        }
+
+        $portfolio = Portfolio::whereUserId($request->user()->id)->first();
+
+        if(!$portfolio) {
+            $portfolio = new Portfolio;
+            $portfolio->user_id = $request->user()->id;
+            $portfolio->profile_id = $request->user()->profile_id;
+            $portfolio->save();
+        }
+
+        return view('portfolio.settings', compact('portfolio'));
+    }
+
+    public function store(Request $request)
+    {
+        abort_unless($request->user(), 404);
+
+        $this->validate($request, [
+            'profile_source' => 'required|in:recent,custom',
+            'layout' => 'required|in:grid,masonry',
+            'layout_container' => 'required|in:fixed,fluid'
+        ]);
+
+        $portfolio = Portfolio::whereUserId($request->user()->id)->first();
+
+        if(!$portfolio) {
+            $portfolio = new Portfolio;
+            $portfolio->user_id = $request->user()->id;
+            $portfolio->profile_id = $request->user()->profile_id;
+            $portfolio->save();
+        }
+
+        $portfolio->active = $request->input('enabled') === 'on';
+        $portfolio->show_captions = $request->input('show_captions') === 'on';
+        $portfolio->show_license = $request->input('show_license') === 'on';
+        $portfolio->show_location = $request->input('show_location') === 'on';
+        $portfolio->show_timestamp = $request->input('show_timestamp') === 'on';
+        $portfolio->show_link = $request->input('show_link') === 'on';
+        $portfolio->profile_source = $request->input('profile_source');
+        $portfolio->show_avatar = $request->input('show_avatar') === 'on';
+        $portfolio->show_bio = $request->input('show_bio') === 'on';
+        $portfolio->profile_layout = $request->input('layout');
+        $portfolio->profile_container = $request->input('layout_container');
+        $portfolio->save();
+
+        return redirect('/' . $request->user()->username);
+    }
+
+    public function getFeed(Request $request, $id)
+    {
+        $user = AccountService::get($id, true);
+
+        if(!$user || !isset($user['id'])) {
+            return response()->json([], 404);
+        }
+
+        $portfolio = Portfolio::whereProfileId($user['id'])->first();
+
+        if(!$portfolio || !$portfolio->active) {
+            return response()->json([], 404);
+        }
+
+        if($portfolio->profile_source === 'custom' && $portfolio->metadata) {
+            return $this->getCustomFeed($portfolio);
+        }
+
+        return $this->getRecentFeed($user['id']);
+    }
+
+    protected function getCustomFeed($portfolio) {
+        if(!$portfolio->metadata['posts']) {
+            return response()->json([], 400);
+        }
+
+        return collect($portfolio->metadata['posts'])->map(function($p) {
+            return StatusService::get($p);
+        })
+        ->filter(function($p) {
+            return $p && isset($p['account']);
+        })->values();
+    }
+
+    protected function getRecentFeed($id) {
+        $media = Cache::remember('portfolio:recent-feed:' . $id, 3600, function() use($id) {
+            return DB::table('media')
+            ->whereProfileId($id)
+            ->whereNotNull('status_id')
+            ->groupBy('status_id')
+            ->orderByDesc('id')
+            ->take(50)
+            ->pluck('status_id');
+        });
+
+        return $media->map(function($sid) use($id) {
+            return StatusService::get($sid);
+        })
+        ->filter(function($post) {
+            return $post &&
+                isset($post['media_attachments']) &&
+                !empty($post['media_attachments']) &&
+                $post['pf_type'] === 'photo' &&
+                $post['visibility'] === 'public';
+        })
+        ->take(24)
+        ->values();
+    }
+
+    public function getSettings(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $res = Portfolio::whereUserId($request->user()->id)->get();
+
+        if(!$res) {
+            return [];
+        }
+
+        return $res->map(function($p) {
+            return [
+                'url' => $p->url(),
+                'pid' => (string) $p->profile_id,
+                'active' => (bool) $p->active,
+                'show_captions' => (bool) $p->show_captions,
+                'show_license' => (bool) $p->show_license,
+                'show_location' => (bool) $p->show_location,
+                'show_timestamp' => (bool) $p->show_timestamp,
+                'show_link' => (bool) $p->show_link,
+                'show_avatar' => (bool) $p->show_avatar,
+                'show_bio' => (bool) $p->show_bio,
+                'profile_layout' => $p->profile_layout,
+                'profile_source' => $p->profile_source,
+                'metadata' => $p->metadata
+            ];
+        })->first();
+    }
+
+    public function getAccountSettings(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required|integer'
+        ]);
+
+        $account = AccountService::get($request->input('id'));
+
+        abort_if(!$account, 404);
+
+        $p = Portfolio::whereProfileId($request->input('id'))->whereActive(1)->firstOrFail();
+
+        if(!$p) {
+            return [];
+        }
+
+        return [
+            'url' => $p->url(),
+            'show_captions' => (bool) $p->show_captions,
+            'show_license' => (bool) $p->show_license,
+            'show_location' => (bool) $p->show_location,
+            'show_timestamp' => (bool) $p->show_timestamp,
+            'show_link' => (bool) $p->show_link,
+            'show_avatar' => (bool) $p->show_avatar,
+            'show_bio' => (bool) $p->show_bio,
+            'profile_layout' => $p->profile_layout,
+            'profile_source' => $p->profile_source
+        ];
+    }
+
+    public function storeSettings(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $this->validate($request, [
+            'profile_layout' => 'sometimes|in:grid,masonry,album'
+        ]);
+
+        $res = Portfolio::whereUserId($request->user()->id)
+        ->update($request->only([
+            'active',
+            'show_captions',
+            'show_license',
+            'show_location',
+            'show_timestamp',
+            'show_link',
+            'show_avatar',
+            'show_bio',
+            'profile_layout',
+            'profile_source'
+        ]));
+
+        Cache::forget('portfolio:recent-feed:' . $request->user()->profile_id);
+
+        return 200;
+    }
+
+    public function storeCurated(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $this->validate($request, [
+            'ids' => 'required|array|max:24'
+        ]);
+
+        $pid = $request->user()->profile_id;
+
+        $ids = $request->input('ids');
+
+        Status::whereProfileId($pid)
+            ->whereScope('public')
+            ->whereIn('type', ['photo', 'photo:album'])
+            ->findOrFail($ids);
+
+        $p = Portfolio::whereProfileId($pid)->firstOrFail();
+        $p->metadata = ['posts' => $ids];
+        $p->save();
+
+        Cache::forget('portfolio:recent-feed:' . $pid);
+
+        return $request->ids;
+    }
+}

+ 39 - 0
app/Models/Portfolio.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use App\Services\AccountService;
+
+class Portfolio extends Model
+{
+    use HasFactory;
+
+    public $fillable = [
+        'active',
+        'show_captions',
+        'show_license',
+        'show_location',
+        'show_timestamp',
+        'show_link',
+        'show_avatar',
+        'show_bio',
+        'profile_layout',
+        'profile_source'
+    ];
+
+    protected $casts = [
+        'metadata' => 'json'
+    ];
+
+    public function url()
+    {
+        $account = AccountService::get($this->profile_id);
+        if(!$account) {
+            return null;
+        }
+
+        return 'https://' . config('portfolio.domain') . config('portfolio.path') . '/' . $account['username'];
+    }
+}

+ 31 - 0
config/portfolio.php

@@ -0,0 +1,31 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Portfolio Domain
+    |--------------------------------------------------------------------------
+    |
+    | This value is the domain used for the portfolio feature. Only change
+    | the default value if you have a subdomain configured. You must use
+    | a subdomain on the same app domain.
+    |
+    */
+    'domain' => env('PORTFOLIO_DOMAIN', config('pixelfed.domain.app')),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Portfolio Path
+    |--------------------------------------------------------------------------
+    |
+    | This value is the path used for the portfolio feature. Only change
+    | the default value if you have a subdomain configured. If you want
+    | to use the root path of the subdomain, leave this value empty.
+    |
+    | WARNING: SETTING THIS VALUE WITHOUT A SUBDOMAIN COULD BREAK YOUR
+    | INSTANCE, SO ONLY CHANGE THIS IF YOU KNOW WHAT YOU'RE DOING.
+    |
+    */
+    'path' => env('PORTFOLIO_PATH', '/i/portfolio'),
+];

+ 45 - 0
database/migrations/2022_01_16_060052_create_portfolios_table.php

@@ -0,0 +1,45 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreatePortfoliosTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('portfolios', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedInteger('user_id')->nullable()->unique()->index();
+            $table->bigInteger('profile_id')->unsigned()->unique()->index();
+            $table->boolean('active')->nullable()->index();
+            $table->boolean('show_captions')->default(true)->nullable();
+            $table->boolean('show_license')->default(true)->nullable();
+            $table->boolean('show_location')->default(true)->nullable();
+            $table->boolean('show_timestamp')->default(true)->nullable();
+            $table->boolean('show_link')->default(true)->nullable();
+            $table->string('profile_source')->default('recent')->nullable();
+            $table->boolean('show_avatar')->default(true)->nullable();
+            $table->boolean('show_bio')->default(true)->nullable();
+            $table->string('profile_layout')->default('grid')->nullable();
+            $table->string('profile_container')->default('fixed')->nullable();
+            $table->json('metadata')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('portfolios');
+    }
+}

TEMPAT SAMPAH
public/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2


TEMPAT SAMPAH
public/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2


+ 122 - 0
resources/assets/js/components/PortfolioPost.vue

@@ -0,0 +1,122 @@
+<template>
+    <div>
+        <div v-if="loading" class="container">
+            <div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
+                <b-spinner />
+            </div>
+        </div>
+
+        <div v-else>
+            <div class="container mb-5">
+                <div class="row mt-3">
+                    <div class="col-12 mb-4">
+
+                        <div class="d-flex justify-content-center">
+                            <img :src="post.media_attachments[0].url" class="img-fluid mb-4" style="max-height: 80vh;object-fit: contain;">
+                        </div>
+
+                    </div>
+                    <div class="col-12 mb-4">
+                        <p v-if="settings.show_captions && post.content_text">{{ post.content_text }}</p>
+                        <div class="d-md-flex justify-content-between align-items-center">
+                            <p class="small text-lighter">by <a :href="profileUrl()" class="text-lighter font-weight-bold">&commat;{{profile.username}}</a></p>
+                            <p v-if="settings.show_license && post.media_attachments[0].license" class="small text-muted">Licensed under {{ post.media_attachments[0].license.title }}</p>
+                            <p v-if="settings.show_location && post.place" class="small text-muted">{{ post.place.name }}, {{ post.place.country }}</p>
+                            <p v-if="settings.show_timestamp" class="small text-muted">
+                                <a v-if="settings.show_link" :href="post.url" class="text-lighter font-weight-bold" style="z-index: 2">
+                                    {{ formatDate(post.created_at) }}
+                                </a>
+                                <span v-else class="user-select-none">
+                                    {{ formatDate(post.created_at) }}
+                                </span>
+                            </p>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="container">
+                <div class="row">
+                    <div class="col-12">
+                        <div class="d-flex fixed-bottom p-3 justify-content-between align-items-center">
+                            <a v-if="user" class="logo-mark logo-mark-sm mb-0 p-1" href="/">
+                                <span class="text-gradient-primary">portfolio</span>
+                            </a>
+                            <span v-else class="logo-mark logo-mark-sm mb-0 p-1">
+                                <span class="text-gradient-primary">portfolio</span>
+                            </span>
+                            <p v-if="user && user.id === profile.id" class="text-center mb-0">
+                                <a :href="settingsUrl" class="text-muted"><i class="far fa-cog fa-lg"></i></a>
+                            </p>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    export default {
+        props: [ 'initialData' ],
+
+        data() {
+            return {
+                loading: true,
+                isAuthed: undefined,
+                user: undefined,
+                settings: undefined,
+                post: undefined,
+                profile: undefined,
+                settingsUrl: window._portfolio.path + '/settings'
+            }
+        },
+
+        mounted() {
+            const initialData = JSON.parse(this.initialData);
+            this.post = initialData.post;
+            this.profile = initialData.profile;
+            this.isAuthed = initialData.authed;
+            this.fetchUser();
+        },
+
+        methods: {
+            async fetchUser() {
+                if(this.isAuthed) {
+                    await axios.get('/api/v1/accounts/verify_credentials')
+                    .then(res => {
+                        this.user = res.data;
+                    })
+                    .catch(err => {
+                    });
+                }
+                await axios.get('/api/portfolio/account/settings.json', {
+                    params: {
+                        id: this.profile.id
+                    }
+                })
+                .then(res => {
+                    this.settings = res.data;
+                })
+                .then(() => {
+                    setTimeout(() => {
+                        this.loading = false;
+                    }, 500);
+                })
+
+            },
+
+            profileUrl() {
+                return `https://${window._portfolio.domain}${window._portfolio.path}/${this.profile.username}`;
+            },
+
+            postUrl(res) {
+                return `/${this.profile.username}/${res.id}`;
+            },
+
+            formatDate(ts) {
+                const dts = new Date(ts);
+                return dts.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'long', day: 'numeric' });
+            }
+        }
+    }
+</script>

+ 223 - 0
resources/assets/js/components/PortfolioProfile.vue

@@ -0,0 +1,223 @@
+<template>
+    <div class="w-100 h-100">
+        <div v-if="loading" class="container">
+            <div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
+                <b-spinner />
+            </div>
+        </div>
+
+        <div v-else class="container">
+            <div class="row py-5">
+                <div class="col-12">
+                    <div class="d-flex align-items-center flex-column">
+                        <img :src="profile.avatar" width="60" height="60" class="rounded-circle shadow" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
+
+                        <div class="py-3 text-center" style="max-width: 60%">
+                            <h1 class="font-weight-bold">{{ profile.username }}</h1>
+                            <p class="font-weight-light mb-0">{{ profile.note_text }}</p>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="container mb-5 pb-5">
+                <div :class="[ settings.profile_layout === 'masonry' ? 'card-columns' : 'row']" id="portContainer">
+                    <template v-if="settings.profile_layout ==='grid'">
+                        <div v-for="(res, index) in feed" class="col-12 col-md-4 mb-1 p-1">
+                            <div class="square">
+                                <a :href="postUrl(res)">
+                                    <img :src="res.media_attachments[0].url" width="100%" height="300" style="overflow: hidden;object-fit: cover;" class="square-content pr-1">
+                                </a>
+                            </div>
+                        </div>
+                    </template>
+
+                    <div v-else-if="settings.profile_layout ==='album'" class="col-12 mb-1 p-1">
+                        <div class="d-flex justify-content-center">
+                            <p class="text-muted font-weight-bold">{{ albumIndex + 1 }} <span class="font-weight-light">/</span> {{ feed.length }}</p>
+                        </div>
+                        <div class="d-flex justify-content-between align-items-center">
+                            <span v-if="albumIndex === 0">
+                                <i class="fa fa-arrow-circle-left fa-3x text-dark" />
+                            </span>
+                            <a v-else @click.prevent="albumPrev()" href="#">
+                                <i class="fa fa-arrow-circle-left fa-3x text-muted"/>
+                            </a>
+                            <transition name="slide-fade">
+                                <a :href="postUrl(feed[albumIndex])" class="mx-4" :key="albumIndex">
+                                    <img
+                                        :src="feed[albumIndex].media_attachments[0].url"
+                                        width="100%"
+                                        class="user-select-none"
+                                        style="height: 60vh; overflow: hidden;object-fit: contain;"
+                                        :draggable="false"
+                                        >
+                                </a>
+                            </transition>
+                            <span v-if="albumIndex === feed.length - 1">
+                                <i class="fa fa-arrow-circle-right fa-3x text-dark" />
+                            </span>
+                            <a v-else @click.prevent="albumNext()" href="#">
+                                <i class="fa fa-arrow-circle-right fa-3x text-muted"/>
+                            </a>
+                        </div>
+                    </div>
+
+                    <div v-else-if="settings.profile_layout ==='masonry'" class="col-12 p-0 m-0">
+                        <div v-for="(res, index) in feed" class="p-1">
+                            <a :href="postUrl(res)" data-fancybox="recent" :data-src="res.media_attachments[0].url" :data-width="res.media_attachments[0].width" :data-height="res.media_attachments[0].height">
+                                <img
+                                    :src="res.media_attachments[0].url"
+                                    width="100%"
+                                    class="user-select-none"
+                                    style="overflow: hidden;object-fit: contain;"
+                                    :draggable="false"
+                                    >
+                            </a>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="d-flex fixed-bottom p-3 justify-content-between align-items-center">
+                <a v-if="user" class="logo-mark logo-mark-sm mb-0 p-1" href="/">
+                    <span class="text-gradient-primary">portfolio</span>
+                </a>
+                <span v-else class="logo-mark logo-mark-sm mb-0 p-1">
+                    <span class="text-gradient-primary">portfolio</span>
+                </span>
+                <p v-if="user && user.id == profile.id" class="text-center mb-0">
+                    <a :href="settingsUrl" class="text-muted"><i class="far fa-cog fa-lg"></i></a>
+                </p>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import '@fancyapps/fancybox/dist/jquery.fancybox.js';
+    import '@fancyapps/fancybox/dist/jquery.fancybox.css';
+
+    export default {
+        props: [ 'initialData' ],
+
+        data() {
+            return {
+                loading: true,
+                user: undefined,
+                profile: undefined,
+                settings: undefined,
+                feed: [],
+                albumIndex: 0,
+                settingsUrl: window._portfolio.path + '/settings'
+            }
+        },
+
+        mounted() {
+            const initialData = JSON.parse(this.initialData);
+            this.profile = initialData.profile;
+            this.fetchUser();
+        },
+
+        methods: {
+            async fetchUser() {
+                axios.get('/api/v1/accounts/verify_credentials')
+                .then(res => {
+                    this.user = res.data;
+                })
+                .catch(err => {
+                });
+
+                await axios.get('/api/portfolio/account/settings.json', {
+                    params: {
+                        id: this.profile.id
+                    }
+                })
+                .then(res => {
+                    this.settings = res.data;
+                })
+                .then(() => {
+                    this.fetchFeed();
+                })
+
+            },
+
+            async fetchFeed() {
+                axios.get('/api/portfolio/' + this.profile.id + '/feed')
+                .then(res => {
+                    this.feed = res.data.filter(p => p.pf_type === "photo");
+                })
+                .then(() => {
+                    this.setAlbumSlide();
+                })
+                .then(() => {
+                    setTimeout(() => {
+                        this.loading = false;
+                    }, 500);
+                })
+                .then(() => {
+                    if(this.settings.profile_layout === 'masonry') {
+                        setTimeout(() => {
+                            this.initMasonry();
+                        }, 500);
+                    }
+                })
+            },
+
+            postUrl(res) {
+                return `${window._portfolio.path}/${this.profile.username}/${res.id}`;
+            },
+
+            albumPrev() {
+                if(this.albumIndex === 0) {
+                    return;
+                }
+                if(this.albumIndex === 1) {
+                    this.albumIndex--;
+                    const url = new URL(window.location);
+                    url.searchParams.delete('slide');
+                    window.history.pushState({}, '', url);
+                    return;
+                }
+                this.albumIndex--;
+                const url = new URL(window.location);
+                url.searchParams.set('slide', this.albumIndex + 1);
+                window.history.pushState({}, '', url);
+            },
+
+            albumNext() {
+                if(this.albumIndex === this.feed.length - 1) {
+                    return;
+                }
+                this.albumIndex++;
+                const url = new URL(window.location);
+                url.searchParams.set('slide', this.albumIndex + 1);
+                window.history.pushState({}, '', url);
+            },
+
+            setAlbumSlide() {
+                const url = new URL(window.location);
+                if(url.searchParams.has('slide')) {
+                    const slide = Number.parseInt(url.searchParams.get('slide'));
+                    if(Number.isNaN(slide)) {
+                        return;
+                    }
+                    if(slide <= 0) {
+                        return;
+                    }
+                    if(slide > this.feed.length) {
+                        return;
+                    }
+                    this.albumIndex = url.searchParams.get('slide') - 1;
+                }
+            },
+
+            initMasonry() {
+                $('[data-fancybox="recent"]').fancybox({
+                    gutter: 20,
+                    modal: false,
+                });
+            }
+        }
+    }
+</script>

+ 459 - 0
resources/assets/js/components/PortfolioSettings.vue

@@ -0,0 +1,459 @@
+<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 24 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 }}/24</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="post.media_attachments[0].url"
+                                                    width="100%"
+                                                    height="300"
+                                                    style="overflow: hidden;object-fit: cover;"
+                                                    :draggable="false"
+                                                    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 col-md-8 mt-3 py-2" key="2">
+                    <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 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>
+                    </div>
+                </div>
+
+                <div v-else-if="tabIndex === 'Share'" class="col-12 col-md-8 bg-dark mt-3 py-2 rounded" key="0">
+                    <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: [],
+                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' },
+                ]
+            }
+        },
+
+        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) {
+                        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;
+                    }
+                })
+                .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() {
+                axios.post(this.apiPath('/api/portfolio/self/update-settings.json'), this.settings)
+                .then(res => {
+                    this.updateTabs();
+                    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')
+                .then(res => {
+                    if(res.data.length) {
+                        this.recentPosts = res.data.filter(p => p.visibility === "public");
+                    }
+                })
+                .then(() => {
+                    setTimeout(() => {
+                        this.recentPostsLoaded = true;
+                    }, 500);
+                })
+            },
+
+            toggleRecentPost(id) {
+                if(this.selectedRecentPosts.indexOf(id) == -1) {
+                    if(this.selectedRecentPosts.length === 24) {
+                        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"
+                            }
+                        ]
+                    },
+                ]
+            }
+        }
+    }
+</script>

+ 19 - 0
resources/assets/js/portfolio.js

@@ -0,0 +1,19 @@
+import Vue from 'vue';
+window.Vue = Vue;
+import BootstrapVue from 'bootstrap-vue'
+Vue.use(BootstrapVue);
+
+Vue.component(
+    'portfolio-post',
+    require('./components/PortfolioPost.vue').default
+);
+
+Vue.component(
+    'portfolio-profile',
+    require('./components/PortfolioProfile.vue').default
+);
+
+Vue.component(
+    'portfolio-settings',
+    require('./components/PortfolioSettings.vue').default
+);

+ 54 - 0
resources/assets/sass/lib/inter.scss

@@ -0,0 +1,54 @@
+/* latin-ext */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 100;
+  font-display: swap;
+  src: url(/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 100;
+  font-display: swap;
+  src: url(/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+  src: url(/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+  src: url(/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: url(/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2) format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: url(/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2) format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}

+ 173 - 0
resources/assets/sass/portfolio.scss

@@ -0,0 +1,173 @@
+@import "lib/inter";
+
+body {
+    background: #000000;
+    font-family: 'Inter', sans-serif;
+    font-weight: 400 !important;
+    color: #d4d4d8;
+}
+
+.text-primary {
+    color: #3B82F6 !important;
+}
+
+.lead,
+.font-weight-light {
+    font-weight: 400 !important;
+}
+
+a {
+    color: #3B82F6;
+    text-decoration: none;
+}
+
+.text-gradient-primary {
+    background: linear-gradient(to right, #6366f1, #8B5CF6, #D946EF);
+    -webkit-background-clip: text;
+    -webkit-text-fill-color: rgba(0,0,0,0);
+}
+
+.logo-mark {
+    border-radius: 1rem;
+    font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif!important;
+    font-weight: 700 !important;
+    letter-spacing: -1.5px;
+    border: 6px solid #212529;
+
+    font-size: 2.5rem;
+    line-height: 1.2;
+
+    user-select: none;
+    color: #fff !important;
+    text-decoration: none !important;
+    background: #212529;
+
+    @media (min-width: 768px) {
+        font-size: 4.5rem;
+    }
+
+    &-sm {
+        font-size: 16px !important;
+        border-width: 3px;
+        border-radius: 10px;
+        letter-spacing: -1px;
+        background: #212529;
+    }
+}
+
+.display-4.font-weight-bold {
+    letter-spacing: -0.3px;
+    text-transform: uppercase;
+
+    @media (min-width: 768px) {
+        letter-spacing: -3px;
+    }
+
+    a {
+        color: #d1d5db;
+        text-decoration: underline;
+    }
+}
+
+.display-4 {
+    font-size: 1.5rem;
+
+    @media (min-width: 768px) {
+        font-size: 3.5rem;
+    }
+}
+
+.btn-primary {
+    background-color: #3B82F6;
+}
+
+.card-columns {
+    -moz-column-count: 3;
+    column-count: 3;
+    -moz-column-gap: 0px;
+    column-gap: 0px;
+    orphans: 1;
+    widows: 1;
+}
+
+.portfolio-settings {
+    .nav-pills {
+        .nav-item {
+            &.disabled {
+                span {
+                    pointer-events: none;
+                    color: #3f3f46;
+                }
+            }
+        }
+
+        .nav-link {
+            font-size: 15px;
+            color: #9ca3af;
+            font-weight: 400;
+
+            &.active {
+                color: #fff;
+                background-image: linear-gradient(to right, #4f46e5 0%, #2F80ED  51%, #4f46e5  100%);
+                background-size: 200% auto;
+                font-weight: 100;
+                transition: 0.5s;
+
+                &:hover {
+                    background-position: right center;
+                }
+            }
+        }
+    }
+
+    .card {
+        &-header {
+            background-color: #000;
+            border: 1px solid var(--dark);
+            font-size: 14px;
+            font-weight: 400;
+            text-transform: uppercase;
+            color: var(--muted);
+        }
+
+        .list-group-item {
+            background: transparent;
+        }
+    }
+
+    .custom-select {
+        border-radius: 10px;
+        font-weight: 700;
+        padding-left: 20px;
+        color: #fff;
+        background: #000 url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right 0.75rem center/8px 10px no-repeat;
+        border-color: var(--dark);
+    }
+
+    .selected-badge {
+        width: 26px;
+        height: 26px;
+        display: flex;
+        border-radius: 26px;
+        background-color: #0284c7;
+        justify-content: center;
+        align-items: center;
+        font-size: 14px;
+        font-weight: 700;
+        color: #fff;
+        border: 2px solid #fff;
+    }
+}
+
+.slide-fade-enter-active {
+    transition: all .3s ease;
+}
+
+.slide-fade-leave-active {
+    transition: all .3s cubic-bezier(1.0, 1.0);
+}
+
+.slide-fade-enter, .slide-fade-leave-to {
+    transform: translateX(10px);
+    opacity: 0;
+}

+ 1 - 1
resources/views/layouts/partial/nav.blade.php

@@ -1,6 +1,6 @@
 <nav class="navbar navbar-expand navbar-light navbar-laravel shadow-none border-bottom sticky-top py-1">
 	<div class="container">
-			<a class="navbar-brand d-flex align-items-center" href="/" title="Logo">
+			<a class="navbar-brand d-flex align-items-center" href="{{ config('app.url') }}" title="Logo">
 				<img src="/img/pixelfed-icon-color.svg" height="30px" class="px-2" loading="eager" alt="Pixelfed logo">
 				<span class="font-weight-bold mb-0 d-none d-sm-block" style="font-size:20px;">{{ config_cache('app.name') }}</span>
 			</a>

+ 21 - 0
resources/views/portfolio/404.blade.php

@@ -0,0 +1,21 @@
+@extends('portfolio.layout')
+
+@section('content')
+<div class="container">
+	<div class="row mt-5 pt-5">
+		<div class="col-12 text-center">
+			<p class="mb-5">
+				<span class="logo-mark px-3"><span class="text-gradient-primary">portfolio</span></span>
+			</p>
+
+            <h1>404 - Not Found</h1>
+
+			<p class="lead pt-3 mb-4">This portfolio or post is either not active or has been removed.</p>
+
+			<p class="mt-3">
+				<a href="{{ config('app.url') }}" class="text-muted" style="text-decoration: underline;">Go back home</a>
+			</p>
+		</div>
+	</div>
+</div>
+@endsection

+ 36 - 0
resources/views/portfolio/index.blade.php

@@ -0,0 +1,36 @@
+@extends('portfolio.layout')
+
+@section('content')
+<div class="container">
+	<div class="row justify-content-center mt-5 pt-5">
+		<div class="col-12 col-md-6 text-center">
+			<p class="mb-3">
+				<span class="logo-mark px-3"><span class="text-gradient-primary">portfolio</span></span>
+			</p>
+
+            <div class="spinner-border mt-5" role="status">
+              <span class="sr-only">Loading...</span>
+            </div>
+		</div>
+
+	</div>
+</div>
+@endsection
+
+@push('scripts')
+<script type="text/javascript">
+	@auth
+	axios.get('/api/v1/accounts/verify_credentials')
+	.then(res => {
+		if(res.data.locked == false) {
+            window.location.href = 'https://{{ config('portfolio.domain') }}{{ config('portfolio.path') }}/' + res.data.username
+		} else {
+            window.location.href = "{{ config('app.url') }}";
+		}
+	})
+    @else
+        window.location.href = "{{ config('app.url') }}";
+	@endauth
+
+</script>
+@endpush

+ 40 - 0
resources/views/portfolio/layout.blade.php

@@ -0,0 +1,40 @@
+<!DOCTYPE html>
+<html lang="{{ app()->getLocale() }}">
+<head>
+
+	<meta charset="utf-8">
+	<meta http-equiv="X-UA-Compatible" content="IE=edge">
+	<meta name="viewport" content="width=device-width, initial-scale=1">
+	<meta name="csrf-token" content="{{ csrf_token() }}">
+
+	<meta name="mobile-web-app-capable" content="yes">
+
+	<title>{!! $title ?? config_cache('app.name') !!}</title>
+
+	<meta property="og:site_name" content="{{ config('app.name', 'pixelfed') }}">
+	<meta property="og:title" content="{{ $title ?? config('app.name', 'pixelfed') }}">
+	<meta property="og:type" content="article">
+	<meta property="og:url" content="{{request()->url()}}">
+	@stack('meta')
+
+	<meta name="medium" content="image">
+	<meta name="theme-color" content="#10c5f8">
+	<meta name="apple-mobile-web-app-capable" content="yes">
+	<link rel="shortcut icon" type="image/png" href="/img/favicon.png?v=2">
+	<link rel="apple-touch-icon" type="image/png" href="/img/favicon.png?v=2">
+	<link rel="canonical" href="{{request()->url()}}">
+	<link href="{{ mix('css/app.css') }}" rel="stylesheet" data-stylesheet="light">
+	<link href="{{ mix('css/portfolio.css') }}" rel="stylesheet" data-stylesheet="light">
+	<script type="text/javascript">window._portfolio = { domain: "{{config('portfolio.domain')}}", path: "{{config('portfolio.path')}}"}</script>
+
+</head>
+<body class="w-100 h-100">
+	<main id="content" class="w-100 h-100">
+		@yield('content')
+	</main>
+	<script type="text/javascript" src="{{ mix('js/manifest.js') }}"></script>
+	<script type="text/javascript" src="{{ mix('js/vendor.js') }}"></script>
+	<script type="text/javascript" src="{{ mix('js/app.js') }}"></script>
+	@stack('scripts')
+</body>
+</html>

+ 23 - 0
resources/views/portfolio/settings.blade.php

@@ -0,0 +1,23 @@
+@extends('portfolio.layout')
+
+@section('content')
+<div class="container">
+	<div class="row mt-5 pt-5 px-0 align-items-center">
+		<div class="col-12 mb-5 col-md-8">
+			<span class="logo-mark px-3"><span class="text-gradient-primary">portfolio</span></span>
+		</div>
+        <div class="col-12 mb-5 col-md-4 text-md-right">
+            <h1 class="font-weight-bold">Settings</h1>
+        </div>
+	</div>
+
+    <portfolio-settings />
+</div>
+@endsection
+
+@push('scripts')
+<script type="text/javascript" src="{{ mix('js/portfolio.js') }}"></script>
+<script type="text/javascript">
+    App.boot();
+</script>
+@endpush

+ 12 - 0
resources/views/portfolio/show.blade.php

@@ -0,0 +1,12 @@
+@extends('portfolio.layout', ['title' => "@{$user['username']}'s Portfolio"])
+
+@section('content')
+<portfolio-profile initial-data="{{json_encode(['profile' => $user])}}" />
+@endsection
+
+@push('scripts')
+<script type="text/javascript" src="{{ mix('js/portfolio.js') }}"></script>
+<script type="text/javascript">
+    App.boot();
+</script>
+@endpush

+ 17 - 0
resources/views/portfolio/show_post.blade.php

@@ -0,0 +1,17 @@
+@extends('portfolio.layout', ['title' => "@{$user['username']}'s Portfolio Photo"])
+
+@section('content')
+<portfolio-post initial-data="{{json_encode(['profile' => $user, 'post' => $post, 'authed' => $authed ? true : false])}}" />
+@endsection
+
+@push('scripts')
+<script type="text/javascript" src="{{ mix('js/portfolio.js') }}"></script>
+<script type="text/javascript">
+    App.boot();
+</script>
+@endpush
+
+@push('meta')<meta property="og:description" content="{{ $post['content_text'] }}">
+    <meta property="og:image" content="{{ $post['media_attachments'][0]['url']}}">
+    <meta name="twitter:card" content="summary_large_image">
+@endpush

+ 31 - 0
routes/web.php

@@ -100,6 +100,28 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio
 	});
 });
 
+Route::domain(config('portfolio.domain'))->group(function () {
+	Route::redirect('redirect/home', config('app.url'));
+	Route::get('/', 'PortfolioController@index');
+	Route::post('api/portfolio/self/curated.json', 'PortfolioController@storeCurated');
+	Route::post('api/portfolio/self/settings.json', 'PortfolioController@getSettings');
+	Route::get('api/portfolio/account/settings.json', 'PortfolioController@getAccountSettings');
+	Route::post('api/portfolio/self/update-settings.json', 'PortfolioController@storeSettings');
+	Route::get('api/portfolio/{username}/feed', 'PortfolioController@getFeed');
+
+	Route::prefix(config('portfolio.path'))->group(function() {
+		Route::get('/', 'PortfolioController@index');
+		Route::get('settings', 'PortfolioController@settings')->name('portfolio.settings');
+		Route::post('settings', 'PortfolioController@store');
+		Route::get('{username}/{id}', 'PortfolioController@showPost');
+		Route::get('{username}', 'PortfolioController@show');
+
+		Route::fallback(function () {
+			return view('errors.404');
+		});
+	});
+});
+
 Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofactor', 'localization'])->group(function () {
 	Route::get('/', 'SiteController@home')->name('timeline.personal');
 
@@ -268,6 +290,14 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
 			Route::post('v1/publish', 'StoryController@publishStory');
 			Route::delete('v1/delete/{id}', 'StoryController@apiV1Delete');
 		});
+
+		Route::group(['prefix' => 'portfolio'], function () {
+			Route::post('self/curated.json', 'PortfolioController@storeCurated');
+			Route::post('self/settings.json', 'PortfolioController@getSettings');
+			Route::get('account/settings.json', 'PortfolioController@getAccountSettings');
+			Route::post('self/update-settings.json', 'PortfolioController@storeSettings');
+			Route::get('{username}/feed', 'PortfolioController@getFeed');
+		});
 	});
 
 	Route::get('discover/tags/{hashtag}', 'DiscoverController@showTags');
@@ -352,6 +382,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
 		Route::post('warning', 'AccountInterstitialController@read');
 		Route::get('my2020', 'SeasonalController@yearInReview');
 
+		Route::get('web/my-portfolio', 'PortfolioController@myRedirect');
 		Route::get('web/hashtag/{tag}', 'SpaController@hashtagRedirect');
 		Route::get('web/username/{id}', 'SpaController@usernameRedirect');
 		Route::get('web/post/{id}', 'SpaController@webPost');