Selaa lähdekoodia

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

Daniel Supernault 2 vuotta sitten
vanhempi
commit
5ad0d8834d

+ 269 - 19
app/Http/Controllers/PortfolioController.php

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

+ 9 - 2
app/Models/Portfolio.php

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

+ 18 - 4
resources/assets/js/components/PortfolioPost.vue

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

+ 104 - 18
resources/assets/js/components/PortfolioProfile.vue

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

+ 263 - 52
resources/assets/js/components/PortfolioSettings.vue

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

+ 4 - 1
resources/assets/js/portfolio.js

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

+ 47 - 3
resources/assets/sass/portfolio.scss

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

+ 20 - 0
resources/views/portfolio/rss_feed.blade.php

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

+ 2 - 0
routes/web.php

@@ -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 () {