mirror of
https://github.com/pixelfed/pixelfed.git
synced 2024-11-22 06:21:27 +00:00
Add Portfolio feature
This commit is contained in:
parent
8a40ef9074
commit
356a882dbc
20 changed files with 1664 additions and 1 deletions
318
app/Http/Controllers/PortfolioController.php
Normal file
318
app/Http/Controllers/PortfolioController.php
Normal 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
39
app/Models/Portfolio.php
Normal 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
31
config/portfolio.php
Normal 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'),
|
||||
];
|
|
@ -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');
|
||||
}
|
||||
}
|
BIN
public/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2
Normal file
BIN
public/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2
Normal file
Binary file not shown.
BIN
public/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2
Normal file
BIN
public/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2
Normal file
Binary file not shown.
122
resources/assets/js/components/PortfolioPost.vue
Normal file
122
resources/assets/js/components/PortfolioPost.vue
Normal 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">@{{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
resources/assets/js/components/PortfolioProfile.vue
Normal file
223
resources/assets/js/components/PortfolioProfile.vue
Normal 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>
|
459
resources/assets/js/components/PortfolioSettings.vue
Normal file
459
resources/assets/js/components/PortfolioSettings.vue
Normal 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
19
resources/assets/js/portfolio.js
vendored
Normal 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
54
resources/assets/sass/lib/inter.scss
vendored
Normal 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
173
resources/assets/sass/portfolio.scss
vendored
Normal 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;
|
||||
}
|
|
@ -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
resources/views/portfolio/404.blade.php
Normal file
21
resources/views/portfolio/404.blade.php
Normal 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
|
36
resources/views/portfolio/index.blade.php
Normal file
36
resources/views/portfolio/index.blade.php
Normal 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
|
40
resources/views/portfolio/layout.blade.php
Normal file
40
resources/views/portfolio/layout.blade.php
Normal 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>
|
23
resources/views/portfolio/settings.blade.php
Normal file
23
resources/views/portfolio/settings.blade.php
Normal 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
|
12
resources/views/portfolio/show.blade.php
Normal file
12
resources/views/portfolio/show.blade.php
Normal 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
|
17
resources/views/portfolio/show_post.blade.php
Normal file
17
resources/views/portfolio/show_post.blade.php
Normal 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
|
|
@ -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');
|
||||
|
|
Loading…
Reference in a new issue