Update Portfolios, add ActivityPub + RSS support, light mode, style customization and more

This commit is contained in:
Daniel Supernault 2023-03-17 01:32:29 -06:00
parent 4e1d0ed596
commit 5ad0d8834d
No known key found for this signature in database
GPG key ID: 0DEF1C662C9033F7
9 changed files with 734 additions and 97 deletions

View file

@ -13,6 +13,10 @@ use App\Services\StatusService;
class PortfolioController extends Controller
{
const RSS_FEED_KEY = 'pf:portfolio:rss-feed:';
const CACHED_FEED_KEY = 'pf:portfolio:cached-feed:';
const RECENT_FEED_KEY = 'pf:portfolio:recent-feed:';
public function index(Request $request)
{
return view('portfolio.index');
@ -60,11 +64,11 @@ class PortfolioController extends Controller
$user = AccountService::get($post['account']['id']);
$portfolio = Portfolio::whereProfileId($user['id'])->first();
if($user['locked'] || $portfolio->active != true) {
if(!$portfolio || $user['locked'] || $portfolio->active != true) {
return view('portfolio.404');
}
if(!$post || $post['visibility'] != 'public' || $post['pf_type'] != 'photo' || $user['id'] != $post['account']['id']) {
if(!$post || $post['visibility'] != 'public' || !in_array($post['pf_type'], ['photo', 'photo:album']) || $user['id'] != $post['account']['id']) {
return view('portfolio.404');
}
@ -117,7 +121,7 @@ class PortfolioController extends Controller
$this->validate($request, [
'profile_source' => 'required|in:recent,custom',
'layout' => 'required|in:grid,masonry',
'layout_container' => 'required|in:fixed,fluid'
'layout_container' => 'required|in:fixed,fluid',
]);
$portfolio = Portfolio::whereUserId($request->user()->id)->first();
@ -140,6 +144,7 @@ class PortfolioController extends Controller
$portfolio->show_bio = $request->input('show_bio') === 'on';
$portfolio->profile_layout = $request->input('layout');
$portfolio->profile_container = $request->input('layout_container');
$portfolio->metadata = $metadata;
$portfolio->save();
return redirect('/' . $request->user()->username);
@ -171,16 +176,24 @@ class PortfolioController extends Controller
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();
$feed = Cache::remember(self::CACHED_FEED_KEY . $portfolio->profile_id, 86400, function() use($portfolio) {
return collect($portfolio->metadata['posts'])->map(function($p) {
return StatusService::get($p);
})
->filter(function($p) {
return $p && isset($p['account']);
});
});
if($portfolio->metadata && isset($portfolio->metadata['feed_order']) && $portfolio->metadata['feed_order'] === 'recent') {
return $feed->reverse()->values();
} else {
return $feed->values();
}
}
protected function getRecentFeed($id) {
$media = Cache::remember('portfolio:recent-feed:' . $id, 3600, function() use($id) {
$media = Cache::remember(self::RECENT_FEED_KEY . $id, 3600, function() use($id) {
return DB::table('media')
->whereProfileId($id)
->whereNotNull('status_id')
@ -215,6 +228,14 @@ class PortfolioController extends Controller
}
return $res->map(function($p) {
$metadata = $p->metadata;
$bgColor = $metadata && isset($metadata['background_color']) ? $metadata['background_color'] : '#000000';
$textColor = $metadata && isset($metadata['text_color']) ? $metadata['text_color'] : '#d4d4d8';
$rssEnabled = $metadata && isset($metadata['rss_enabled']) ? $metadata['rss_enabled'] : false;
$rssButton = $metadata && isset($metadata['show_rss_button']) ? $metadata['show_rss_button'] : false;
$colorScheme = $metadata && isset($metadata['color_scheme']) ? $metadata['color_scheme'] : 'dark';
$feedOrder = $metadata && isset($metadata['feed_order']) ? $metadata['feed_order'] : 'oldest';
return [
'url' => $p->url(),
'pid' => (string) $p->profile_id,
@ -228,6 +249,13 @@ class PortfolioController extends Controller
'show_bio' => (bool) $p->show_bio,
'profile_layout' => $p->profile_layout,
'profile_source' => $p->profile_source,
'color_scheme' => $colorScheme,
'background_color' => $bgColor,
'text_color' => $textColor,
'show_profile_button' => true,
'rss_enabled' => $rssEnabled,
'show_rss_button' => $rssButton,
'feed_order' => $feedOrder,
'metadata' => $p->metadata
];
})->first();
@ -248,8 +276,13 @@ class PortfolioController extends Controller
if(!$p) {
return [];
}
$metadata = $p->metadata;
return [
$rssEnabled = $metadata && isset($metadata['rss_enabled']) ? $metadata['rss_enabled'] : false;
$rssButton = $metadata && isset($metadata['show_rss_button']) ? $metadata['show_rss_button'] : false;
$profileButton = $metadata && isset($metadata['show_profile_button']) ? $metadata['show_profile_button'] : false;
$res = [
'url' => $p->url(),
'show_captions' => (bool) $p->show_captions,
'show_license' => (bool) $p->show_license,
@ -259,8 +292,27 @@ class PortfolioController extends Controller
'show_avatar' => (bool) $p->show_avatar,
'show_bio' => (bool) $p->show_bio,
'profile_layout' => $p->profile_layout,
'profile_source' => $p->profile_source
'profile_source' => $p->profile_source,
'show_profile_button' => $profileButton,
'rss_enabled' => $rssEnabled,
'show_rss_button' => $rssButton,
];
if($rssEnabled) {
$res['rss_feed_url'] = $p->permalink('.rss');
}
if($p->metadata) {
if(isset($p->metadata['background_color'])) {
$res['background_color'] = $p->metadata['background_color'];
}
if(isset($p->metadata['text_color'])) {
$res['text_color'] = $p->metadata['text_color'];
}
}
return $res;
}
public function storeSettings(Request $request)
@ -268,11 +320,99 @@ class PortfolioController extends Controller
abort_if(!$request->user(), 403);
$this->validate($request, [
'profile_layout' => 'sometimes|in:grid,masonry,album'
'active' => 'sometimes|boolean',
'show_captions' => 'sometimes|boolean',
'show_license' => 'sometimes|boolean',
'show_location' => 'sometimes|boolean',
'show_timestamp' => 'sometimes|boolean',
'show_link' => 'sometimes|boolean',
'show_avatar' => 'sometimes|boolean',
'show_bio' => 'sometimes|boolean',
'profile_layout' => 'sometimes|in:grid,masonry,album',
'profile_source' => 'sometimes|in:recent,custom',
'color_scheme' => 'sometimes|in:light,dark,custom',
'show_profile_button' => 'sometimes|boolean',
'rss_enabled' => 'sometimes|boolean',
'show_rss_button' => 'sometimes|boolean',
'feed_order' => 'sometimes|in:oldest,recent',
'background_color' => [
'sometimes',
'nullable',
'regex:/^#([a-f0-9]{6}|[a-f0-9]{3})$/i'
],
'text_color' => [
'sometimes',
'nullable',
'regex:/^#([a-f0-9]{6}|[a-f0-9]{3})$/i'
],
]);
$res = Portfolio::whereUserId($request->user()->id)
->update($request->only([
$res = Portfolio::whereUserId($request->user()->id)->firstOrFail();
$pid = $request->user()->profile_id;
$metadata = $res->metadata;
$clearFeedCache = false;
if($request->has('color_scheme')) {
$metadata['color_scheme'] = $request->input('color_scheme');
}
if($request->has('background_color')) {
$metadata['background_color'] = $request->input('background_color');
$bgc = $request->background_color;
if($bgc && $bgc !== '#000000') {
$metadata['color_scheme'] = 'custom';
}
}
if($request->has('text_color')) {
$metadata['text_color'] = $request->input('text_color');
$txc = $request->text_color;
if($txc && $txc !== '#d4d4d8') {
$metadata['color_scheme'] = 'custom';
}
}
if($request->has('show_profile_button')) {
$metadata['show_profile_button'] = $request->input('show_profile_button');
}
if($request->has('rss_enabled')) {
$metadata['rss_enabled'] = $request->input('rss_enabled');
}
if($request->has('show_rss_button')) {
$metadata['show_rss_button'] = $metadata['rss_enabled'] ? $request->input('show_rss_button') : false;
}
if($request->has('feed_order')) {
$metadata['feed_order'] = $request->input('feed_order');
}
if(isset($metadata['background_color']) || isset($metadata['text_color'])) {
$bgc = isset($metadata['background_color']) ? $metadata['background_color'] : null;
$txc = isset($metadata['text_color']) ? $metadata['text_color'] : null;
if((!$bgc || $bgc == '#000000') && (!$txc || $txc === '#d4d4d8') && $request->color_scheme != 'light') {
$metadata['color_scheme'] = 'dark';
}
}
if($request->has('color_scheme') && $request->color_scheme === 'light') {
$metadata['background_color'] = '#ffffff';
$metadata['text_color'] = '#000000';
$metadata['color_scheme'] = 'light';
}
if($request->metadata !== $metadata) {
$res->metadata = $metadata;
$res->save();
}
if($request->profile_layout != $res->profile_layout) {
$clearFeedCache = true;
}
$res->update($request->only([
'active',
'show_captions',
'show_license',
@ -285,7 +425,11 @@ class PortfolioController extends Controller
'profile_source'
]));
Cache::forget('portfolio:recent-feed:' . $request->user()->profile_id);
Cache::forget(self::RECENT_FEED_KEY . $pid);
if($clearFeedCache) {
Cache::forget(self::RSS_FEED_KEY . $pid);
}
return 200;
}
@ -295,7 +439,7 @@ class PortfolioController extends Controller
abort_if(!$request->user(), 403);
$this->validate($request, [
'ids' => 'required|array|max:24'
'ids' => 'required|array|max:100'
]);
$pid = $request->user()->profile_id;
@ -308,11 +452,117 @@ class PortfolioController extends Controller
->findOrFail($ids);
$p = Portfolio::whereProfileId($pid)->firstOrFail();
$p->metadata = ['posts' => $ids];
$metadata = $p->metadata;
$metadata['posts'] = $ids;
$p->metadata = $metadata;
$p->save();
Cache::forget('portfolio:recent-feed:' . $pid);
Cache::forget(self::RECENT_FEED_KEY . $pid);
Cache::forget(self::RSS_FEED_KEY . $pid);
Cache::forget(self::CACHED_FEED_KEY . $pid);
return $request->ids;
}
public function getRssFeed(Request $request, $username)
{
$user = User::whereUsername($username)->first();
if(!$user) {
return view('portfolio.404');
}
$portfolio = Portfolio::whereUserId($user->id)->where('active', 1)->firstOrFail();
$metadata = $portfolio->metadata;
abort_if(!$metadata || !isset($metadata['rss_enabled']), 404);
abort_unless($metadata['rss_enabled'], 404);
$account = AccountService::get($user->profile_id);
$portfolioUrl = $portfolio->url();
$portfolioLayout = $portfolio->profile_layout;
if(!isset($metadata['posts']) || !count($metadata['posts'])) {
$feed = [];
} else {
$feed = Cache::remember(
self::RSS_FEED_KEY . $user->profile_id,
43200,
function() use($portfolio, $portfolioUrl, $portfolioLayout) {
return collect($portfolio->metadata['posts'])->map(function($post) {
return StatusService::get($post);
})
->filter()
->values()
->map(function($post, $idx) use($portfolioLayout, $portfolioUrl) {
$ts = now()->parse($post['created_at']);
$url = $portfolioLayout == 'album' ? $portfolioUrl . '?slide=' . ($idx + 1) : $portfolioUrl . '/' . $post['id'];
return [
'title' => 'Post by ' . $post['account']['username'] . ' on ' . $ts->format('D, d M Y'),
'description' => $post['content_text'],
'pubDate' => date('D, d M Y H:i:s ', strtotime($post['created_at'])) . 'GMT',
'url' => $url
];
})
->reverse()
->take(10)
->toArray();
}
);
}
$now = date('D, d M Y H:i:s ') . 'GMT';
return response()
->view('portfolio.rss_feed', compact('account', 'now', 'feed', 'portfolioUrl'), 200)
->header('Content-Type', 'text/xml');
return response($feed)->withHeaders(['Content-Type' => 'text/xml']);
}
public function getApFeed(Request $request, $username)
{
$user = User::whereUsername($username)->first();
if(!$user) {
return view('portfolio.404');
}
$portfolio = Portfolio::whereUserId($user->id)->where('active', 1)->firstOrFail();
$metadata = $portfolio->metadata;
$baseUrl = config('app.url');
$page = $request->input('page');
$res = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $portfolio->permalink('.json'),
'type' => 'OrderedCollection',
'totalItems' => isset($metadata['posts']) ? count($metadata['posts']) : 0,
];
if($request->has('page')) {
$start = $page == 1 ? 0 : ($page * 10 - 10);
$res['id'] = $portfolio->permalink('.json?page=' . $page);
$res['type'] = 'OrderedCollectionPage';
$res['next'] = $portfolio->permalink('.json?page=' . $page + 1);
$res['partOf'] = $portfolio->permalink('.json');
$res['orderedItems'] = collect($metadata['posts'])->slice($start)->take(10)->map(function($p) {
return StatusService::get($p);
})
->filter()
->map(function($p) {
return $p['url'];
})
->values();
if(!$res['orderedItems'] || $res['orderedItems']->count() != 10) {
unset($res['next']);
}
} else {
$res['first'] = $portfolio->permalink('.json?page=1');
}
return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT)
->header('Content-Type', 'application/activity+json');
}
}

View file

@ -28,13 +28,20 @@ class Portfolio extends Model
'metadata' => 'json'
];
public function url()
public function url($suffix = '')
{
$account = AccountService::get($this->profile_id);
if(!$account) {
return null;
}
return 'https://' . config('portfolio.domain') . config('portfolio.path') . '/' . $account['username'];
return 'https://' . config('portfolio.domain') . config('portfolio.path') . '/' . $account['username'] . $suffix;
}
public function permalink($suffix = '')
{
$account = AccountService::get($this->profile_id);
return config('app.url') . '/account/portfolio/' . $account['username'] . $suffix;
}
}

View file

@ -19,11 +19,11 @@
<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 class="small">by <a :href="profileUrl()" class="font-weight-bold link-color">&commat;{{profile.username}}</a></p>
<p v-if="settings.show_license && post.media_attachments[0].license" class="small">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">
<p v-if="settings.show_timestamp" class="small">
<a v-if="settings.show_link" :href="post.url" class="font-weight-bold link-color" style="z-index: 2">
{{ formatDate(post.created_at) }}
</a>
<span v-else class="user-select-none">
@ -96,6 +96,15 @@
})
.then(res => {
this.settings = res.data;
if(res.data.hasOwnProperty('background_color')) {
this.updateCssVariable('--body-bg', res.data.background_color);
}
if(res.data.hasOwnProperty('text_color')) {
this.updateCssVariable('--text-color', res.data.text_color);
this.updateCssVariable('--link-color', res.data.text_color);
}
})
.then(() => {
setTimeout(() => {
@ -116,6 +125,11 @@
formatDate(ts) {
const dts = new Date(ts);
return dts.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'long', day: 'numeric' });
},
updateCssVariable(k, v) {
let rs = document.querySelector(':root');
rs.style.setProperty(k, v);
}
}
}

View file

@ -10,11 +10,35 @@
<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';">
<img
v-if="settings.show_avatar"
:src="profile.avatar"
width="60"
height="60"
class="rounded-circle shadow"
onerror="this.src='/storage/avatars/default.png?v=0';this.onerror=null;">
<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>
<p v-if="settings.show_bio" class="font-weight-light mb-0 text-break">{{ profile.note_text }}</p>
</div>
<div v-if="settings.show_profile_button || (settings.rss_enabled && settings.show_rss_button)" class="pb-3 text-center d-flex flex-column flex-sm-row" style="max-width: 60%;gap: 1rem;">
<a
v-if="settings.show_profile_button"
class="btn btn-outline-primary btn-custom-color"
:href="profile.url"
target="_blank">
View Profile
</a>
<a
v-if="settings.rss_enabled && settings.show_rss_button"
class="btn btn-outline-primary btn-custom-color"
:href="settings.rss_feed_url"
target="_blank">
<i class="far fa-rss"></i> &nbsp; RSS
</a>
</div>
</div>
</div>
@ -26,7 +50,23 @@
<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">
<div class="lazy-img">
<blur-hash-canvas
width="32"
height="32"
:hash="res.media_attachments[0].blurhash"
class="square-content pr-1"
/>
<img
src=""
:data-src="res.media_attachments[0].url"
width="100%"
height="300"
style="overflow: hidden;object-fit: cover;z-index: -1;"
class="square-content pr-1 img-placeholder"
loading="lazy" />
</div>
</a>
</div>
</div>
@ -34,14 +74,14 @@
<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>
<p class="text-color 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" />
<i class="fa fa-arrow-circle-left fa-3x text-color-lighter" />
</span>
<a v-else @click.prevent="albumPrev()" href="#">
<i class="fa fa-arrow-circle-left fa-3x text-muted"/>
<i class="fa fa-arrow-circle-left fa-3x text-color"/>
</a>
<transition name="slide-fade">
<a :href="postUrl(feed[albumIndex])" class="mx-4" :key="albumIndex">
@ -55,10 +95,10 @@
</a>
</transition>
<span v-if="albumIndex === feed.length - 1">
<i class="fa fa-arrow-circle-right fa-3x text-dark" />
<i class="fa fa-arrow-circle-right fa-3x text-color-lighter" />
</span>
<a v-else @click.prevent="albumNext()" href="#">
<i class="fa fa-arrow-circle-right fa-3x text-muted"/>
<i class="fa fa-arrow-circle-right fa-3x text-color"/>
</a>
</div>
</div>
@ -66,13 +106,13 @@
<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"
>
<img
:src="res.media_attachments[0].url"
width="100%"
class="user-select-none"
style="overflow: hidden;object-fit: contain;"
:draggable="false"
>
</a>
</div>
</div>
@ -87,7 +127,7 @@
<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>
<a :href="settingsUrl" class="link-color"><i class="far fa-cog fa-lg"></i></a>
</p>
</div>
</div>
@ -109,7 +149,7 @@
settings: undefined,
feed: [],
albumIndex: 0,
settingsUrl: window._portfolio.path + '/settings'
settingsUrl: window._portfolio.path + '/settings',
}
},
@ -135,6 +175,15 @@
})
.then(res => {
this.settings = res.data;
if(res.data.hasOwnProperty('background_color')) {
this.updateCssVariable('--body-bg', res.data.background_color);
}
if(res.data.hasOwnProperty('text_color')) {
this.updateCssVariable('--text-color', res.data.text_color);
this.updateCssVariable('--link-color', res.data.text_color);
}
})
.then(() => {
this.fetchFeed();
@ -145,7 +194,7 @@
async fetchFeed() {
axios.get('/api/portfolio/' + this.profile.id + '/feed')
.then(res => {
this.feed = res.data.filter(p => p.pf_type === "photo");
this.feed = res.data.filter(p => ['photo', 'photo:album'].includes(p.pf_type));
})
.then(() => {
this.setAlbumSlide();
@ -162,6 +211,11 @@
}, 500);
}
})
.then(() => {
setTimeout(() => {
this.bootIntersectors()
}, 500);
})
},
postUrl(res) {
@ -217,6 +271,38 @@
gutter: 20,
modal: false,
});
},
updateCssVariable(k, v) {
let rs = document.querySelector(':root');
rs.style.setProperty(k, v);
},
bootIntersectors() {
var lazyImages = [].slice.call(document.querySelectorAll("img.img-placeholder"));
if ("IntersectionObserver" in window) {
let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
let lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImage.style.zIndex = 2;
lazyImage.classList.remove("img-placeholder");
lazyImageObserver.unobserve(lazyImage);
}
});
});
lazyImages.forEach(function(lazyImage) {
lazyImageObserver.observe(lazyImage);
});
} else {
lazyImages.forEach(function(img) {
img.src = img.dataset.src;
img.style.zIndex = 2;
})
}
}
}
}

View file

@ -55,10 +55,10 @@
<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>
<p class="text-muted small">Select up to 100 photos from your 100 most recent posts. You can only select public photo posts, videos are not supported at this time.</p>
<div class="d-flex align-items-center justify-content-between">
<p class="font-weight-bold mb-0">Selected {{ selectedRecentPosts.length }}/24</p>
<p class="font-weight-bold mb-0">Selected {{ selectedRecentPosts.length }}/100</p>
<div>
<button
class="btn btn-link font-weight-bold mr-3 text-decoration-none"
@ -90,11 +90,13 @@
<transition name="fade">
<img
:key="post.id"
:src="post.media_attachments[0].url"
:src="getPreviewUrl(post)"
width="100%"
height="300"
style="overflow: hidden;object-fit: cover;"
:draggable="false"
loading="lazy"
onerror="this.src='/storage/no-preview.png';this.onerror=null;"
class="square-content pr-1">
</transition>
@ -112,45 +114,128 @@
</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 v-else-if="tabIndex === 'Customize'" class="col-12 mt-3 py-2" key="2">
<div class="row">
<div class="col-12 col-md-6">
<div v-for="setting in customizeSettings" class="card bg-dark mb-5">
<div class="card-header">{{ setting.title }}</div>
<div class="list-group bg-dark">
<div v-for="item in setting.items" class="list-group-item">
<div class="d-flex justify-content-between align-items-center py-2">
<div class="setting-label">
<p class="mb-0">{{ item.label }}</p>
<p v-if="item.description" class="small text-muted mb-0">{{ item.description }}</p>
</div>
<div class="setting-switch mt-n1">
<b-form-checkbox
v-model="settings[item.model]"
name="check-button"
size="lg"
switch
:disabled="item.requiredWithTrue && !settings[item.requiredWithTrue]" />
</div>
</div>
</div>
</div>
</div>
<div 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 class="setting-switch mt-n1">
<b-form-checkbox
v-model="settings[item.model]"
name="check-button"
size="lg"
switch
:disabled="item.requiredWithTrue && !settings[item.requiredWithTrue]" />
</div>
</div>
</div>
</div>
</div>
</div>
<div>
<b-form-select v-model="settings.profile_layout" :options="profileLayoutOptions" />
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="card bg-dark mb-5">
<div class="card-header">Portfolio</div>
<div class="list-group bg-dark">
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center py-2">
<div class="setting-label">
<p class="mb-0">Layout</p>
</div>
<div>
<b-form-select v-model="settings.profile_layout" :options="profileLayoutOptions" />
</div>
</div>
</div>
<div v-if="settings.profile_source === 'custom'" class="list-group-item">
<div class="d-flex justify-content-between align-items-center py-2">
<div class="setting-label">
<p class="mb-0">Order</p>
</div>
<div>
<b-form-select
v-model="settings.feed_order"
:options="profileLayoutFeedOrder" />
</div>
</div>
</div>
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center py-2">
<div class="setting-label">
<p class="mb-0">Color Scheme</p>
</div>
<div>
<b-form-select
v-model="settings.color_scheme"
:options="profileLayoutColorSchemeOptions"
:disabled="settings.color_scheme === 'custom'"
@change="updateColorScheme" />
</div>
</div>
</div>
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center py-2">
<div class="setting-label">
<p class="mb-0">Background Color</p>
</div>
<b-col sm="2">
<b-form-input
v-model="settings.background_color"
debounce="1000"
type="color"
@change="updateBackgroundColor" />
<b-button
v-if="!['#000000', null].includes(settings.background_color)"
variant="link"
@click="resetBackgroundColor">
Reset
</b-button>
</b-col>
</div>
</div>
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center py-2">
<div class="setting-label">
<p class="mb-0">Text Color</p>
</div>
<b-col sm="2">
<b-form-input
v-model="settings.text_color"
debounce="1000"
type="color"
@change="updateTextColor" />
<b-button
v-if="!['#d4d4d8', null].includes(settings.text_color)"
variant="link"
@click="resetTextColor">
Reset
</b-button>
</b-col>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="tabIndex === 'Share'" class="col-12 col-md-8 bg-dark mt-3 py-2 rounded" key="0">
@ -185,6 +270,7 @@
isSavingCurated: false,
canSaveCurated: false,
customizeSettings: [],
skipWatch: false,
profileSourceOptions: [
{ value: null, text: 'Please select an option', disabled: true },
{ value: 'recent', text: 'Most recent posts' },
@ -194,6 +280,16 @@
{ value: 'grid', text: 'Grid' },
{ value: 'masonry', text: 'Masonry' },
{ value: 'album', text: 'Album' },
],
profileLayoutColorSchemeOptions: [
{ value: null, text: 'Please select an option', disabled: true },
{ value: 'light', text: 'Light mode' },
{ value: 'dark', text: 'Dark mode' },
{ value: 'custom', text: 'Custom color scheme', disabled: true },
],
profileLayoutFeedOrder: [
{ value: 'oldest', text: 'Oldest first' },
{ value: 'recent', text: 'Recent first' }
]
}
},
@ -217,7 +313,7 @@
deep: true,
immediate: true,
handler: function(o, n) {
if(this.loading) {
if(this.loading || this.skipWatch) {
return;
}
if(!n.show_timestamp) {
@ -260,6 +356,20 @@
if(res.data.metadata && res.data.metadata.posts) {
this.selectedRecentPosts = res.data.metadata.posts;
}
if(res.data.color_scheme != 'dark') {
if(res.data.color_scheme === 'light') {
this.updateBackgroundColor('#ffffff');
} else {
if(res.data.hasOwnProperty('background_color')) {
this.updateBackgroundColor(res.data.background_color);
}
if(res.data.hasOwnProperty('text_color')) {
this.updateTextColor(res.data.text_color);
}
}
}
})
.then(() => {
this.initCustomizeSettings();
@ -325,16 +435,22 @@
}
},
updateSettings() {
updateSettings(silent = false) {
if(this.skipWatch) {
return;
}
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
})
if(!silent) {
this.$bvToast.toast(`Your settings have been successfully updated!`, {
variant: 'dark',
title: 'Settings Updated',
autoHideDelay: 2000,
appendToast: false
})
}
})
},
@ -354,7 +470,7 @@
toggleRecentPost(id) {
if(this.selectedRecentPosts.indexOf(id) == -1) {
if(this.selectedRecentPosts.length === 24) {
if(this.selectedRecentPosts.length === 100) {
return;
}
this.selectedRecentPosts.push(id);
@ -449,10 +565,105 @@
{
label: "Show Bio",
model: "show_bio"
}
},
{
label: "Show View Profile Button",
model: "show_profile_button"
},
{
label: "Enable RSS Feed",
description: "Enable your RSS feed with the 10 most recent portfolio items",
model: "rss_enabled"
},
{
label: "Show RSS Feed Button",
model: "show_rss_button",
requiredWithTrue: "rss_enabled"
},
]
},
]
},
updateBackgroundColor(e) {
this.skipWatch = true;
let rs = document.querySelector(':root');
rs.style.setProperty('--body-bg', e);
if(e !== '#000000' && e !== '#ffffff') {
this.settings.color_scheme = 'custom';
}
this.$nextTick(() => {
this.skipWatch = false;
});
},
updateTextColor(e) {
this.skipWatch = true;
let rs = document.querySelector(':root');
rs.style.setProperty('--text-color', e);
if(e !== '#d4d4d8') {
this.settings.color_scheme = 'custom';
}
this.$nextTick(() => {
this.skipWatch = false;
});
},
resetBackgroundColor() {
this.skipWatch = true;
this.$nextTick(() => {
this.updateBackgroundColor('#000000');
this.settings.color_scheme = 'dark';
this.settings.background_color = '#000000';
this.updateSettings(true);
setTimeout(() => {
this.skipWatch = false;
}, 1000);
});
},
resetTextColor() {
this.skipWatch = true;
this.$nextTick(() => {
this.updateTextColor('#d4d4d8');
this.settings.color_scheme = 'dark';
this.settings.text_color = '#d4d4d8';
this.updateSettings(true);
setTimeout(() => {
this.skipWatch = false;
}, 1000);
});
},
updateColorScheme(e) {
if(e === 'light') {
this.updateBackgroundColor('#ffffff');
}
if(e === 'dark') {
this.updateBackgroundColor('#000000');
}
},
getPreviewUrl(post) {
let media = post.media_attachments[0];
if(!media) { return '/storage/no-preview.png'; }
if(media.preview_url && !media.preview_url.endsWith('/no-preview.png')) {
return media.preview_url;
}
return media.url;
}
}
}

View file

@ -1,7 +1,10 @@
import Vue from 'vue';
window.Vue = Vue;
import BootstrapVue from 'bootstrap-vue'
import BootstrapVue from 'bootstrap-vue';
import VueBlurHash from 'vue-blurhash';
import 'vue-blurhash/dist/vue-blurhash.css'
Vue.use(BootstrapVue);
Vue.use(VueBlurHash);
Vue.component(
'portfolio-post',

View file

@ -1,23 +1,67 @@
@import "lib/inter";
:root {
--body-bg: #000000;
--text-color: #d4d4d8;
--link-color: #3B82F6;
};
body {
background: #000000;
background: var(--body-bg);
font-family: 'Inter', sans-serif;
font-weight: 400 !important;
color: #d4d4d8;
color: var(--text-color);
}
.text-primary {
color: #3B82F6 !important;
}
.text-color {
color: var(--text-color);
}
.text-color-lighter {
color: var(--text-color);
opacity: 0.3;
}
.btn-custom-color {
border-color: var(--link-color);
color: var(--link-color);
font-weight: bold;
padding: 7px 30px;
border-radius: 20px;
font-size: 13px;
&:active,
&:hover,
&:focus {
border-color: var(--link-color) !important;
color: var(--link-color) !important;
background-color: transparent !important;
opacity: 0.5;
}
}
.link-color {
color: var(--link-color);
&:active,
&:hover,
&:focus {
color: var(--link-color);
opacity: 0.5;
}
}
.lead,
.font-weight-light {
font-weight: 400 !important;
}
a {
color: #3B82F6;
color: var(--link-color);
text-decoration: none;
}

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- RSS generated by pixelfed v{{config('pixelfed.version')}} on {{$now}} -->
<rss version="2.0">
<channel>
<title>{{ $account['username'] }}'s Portfolio</title>
<link>{{ $portfolioUrl }}</link>
<description>The pixelfed portfolio of {{ $account['username'] }} with the {{ count($feed) }} most recent posts</description>
<pubDate>{{ $now }}</pubDate>
<language>en-us</language>
@foreach($feed as $p)
<item>
<title>{{$p['title']}}</title>
<description>{{$p['description']}}</description>
<guid>{{$p['url']}}</guid>
<link>{{$p['url']}}</link>
<pubDate>{{$p['pubDate']}}</pubDate>
</item>
@endforeach
</channel>
</rss>

View file

@ -407,6 +407,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::get('follow-requests', 'AccountController@followRequests')->name('follow-requests');
Route::post('follow-requests', 'AccountController@followRequestHandle');
Route::get('follow-requests.json', 'AccountController@followRequestsJson');
Route::get('portfolio/{username}.json', 'PortfolioController@getApFeed');
Route::get('portfolio/{username}.rss', 'PortfolioController@getRssFeed');
});
Route::group(['prefix' => 'settings'], function () {