Add Portfolio feature

This commit is contained in:
Daniel Supernault 2022-10-17 01:59:23 -06:00
parent 8a40ef9074
commit 356a882dbc
No known key found for this signature in database
GPG Key ID: 0DEF1C662C9033F7
20 changed files with 1664 additions and 1 deletions

View File

@ -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
app/Models/Portfolio.php Normal file
View File

@ -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
config/portfolio.php Normal file
View File

@ -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'),
];

View File

@ -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');
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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
resources/assets/js/portfolio.js vendored Normal file
View File

@ -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
resources/assets/sass/lib/inter.scss vendored Normal file
View File

@ -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
resources/assets/sass/portfolio.scss vendored Normal file
View File

@ -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;
}

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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');