diff --git a/app/Http/Controllers/PortfolioController.php b/app/Http/Controllers/PortfolioController.php new file mode 100644 index 000000000..5890f2d0e --- /dev/null +++ b/app/Http/Controllers/PortfolioController.php @@ -0,0 +1,318 @@ +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; + } +} diff --git a/app/Models/Portfolio.php b/app/Models/Portfolio.php new file mode 100644 index 000000000..758e8db49 --- /dev/null +++ b/app/Models/Portfolio.php @@ -0,0 +1,39 @@ + 'json' + ]; + + public function url() + { + $account = AccountService::get($this->profile_id); + if(!$account) { + return null; + } + + return 'https://' . config('portfolio.domain') . config('portfolio.path') . '/' . $account['username']; + } +} diff --git a/config/portfolio.php b/config/portfolio.php new file mode 100644 index 000000000..9b4cd8f4b --- /dev/null +++ b/config/portfolio.php @@ -0,0 +1,31 @@ + 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'), +]; diff --git a/database/migrations/2022_01_16_060052_create_portfolios_table.php b/database/migrations/2022_01_16_060052_create_portfolios_table.php new file mode 100644 index 000000000..73d639cc4 --- /dev/null +++ b/database/migrations/2022_01_16_060052_create_portfolios_table.php @@ -0,0 +1,45 @@ +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'); + } +} diff --git a/public/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2 b/public/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2 new file mode 100644 index 000000000..980853fec Binary files /dev/null and b/public/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2 differ diff --git a/public/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2 b/public/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2 new file mode 100644 index 000000000..edd834682 Binary files /dev/null and b/public/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2 differ diff --git a/resources/assets/js/components/PortfolioPost.vue b/resources/assets/js/components/PortfolioPost.vue new file mode 100644 index 000000000..595485e41 --- /dev/null +++ b/resources/assets/js/components/PortfolioPost.vue @@ -0,0 +1,122 @@ + + + diff --git a/resources/assets/js/components/PortfolioProfile.vue b/resources/assets/js/components/PortfolioProfile.vue new file mode 100644 index 000000000..ecd38ffe2 --- /dev/null +++ b/resources/assets/js/components/PortfolioProfile.vue @@ -0,0 +1,223 @@ + + + diff --git a/resources/assets/js/components/PortfolioSettings.vue b/resources/assets/js/components/PortfolioSettings.vue new file mode 100644 index 000000000..a9ede8bdc --- /dev/null +++ b/resources/assets/js/components/PortfolioSettings.vue @@ -0,0 +1,459 @@ + + + diff --git a/resources/assets/js/portfolio.js b/resources/assets/js/portfolio.js new file mode 100644 index 000000000..3d9980ae3 --- /dev/null +++ b/resources/assets/js/portfolio.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +window.Vue = Vue; +import BootstrapVue from 'bootstrap-vue' +Vue.use(BootstrapVue); + +Vue.component( + 'portfolio-post', + require('./components/PortfolioPost.vue').default +); + +Vue.component( + 'portfolio-profile', + require('./components/PortfolioProfile.vue').default +); + +Vue.component( + 'portfolio-settings', + require('./components/PortfolioSettings.vue').default +); diff --git a/resources/assets/sass/lib/inter.scss b/resources/assets/sass/lib/inter.scss new file mode 100644 index 000000000..f360b88ef --- /dev/null +++ b/resources/assets/sass/lib/inter.scss @@ -0,0 +1,54 @@ +/* latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: url(/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2) format('woff2'); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: url(/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2) format('woff2'); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa25L7W0Q5n-wU.woff2) format('woff2'); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(/fonts/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/resources/assets/sass/portfolio.scss b/resources/assets/sass/portfolio.scss new file mode 100644 index 000000000..073fe96e8 --- /dev/null +++ b/resources/assets/sass/portfolio.scss @@ -0,0 +1,173 @@ +@import "lib/inter"; + +body { + background: #000000; + font-family: 'Inter', sans-serif; + font-weight: 400 !important; + color: #d4d4d8; +} + +.text-primary { + color: #3B82F6 !important; +} + +.lead, +.font-weight-light { + font-weight: 400 !important; +} + +a { + color: #3B82F6; + text-decoration: none; +} + +.text-gradient-primary { + background: linear-gradient(to right, #6366f1, #8B5CF6, #D946EF); + -webkit-background-clip: text; + -webkit-text-fill-color: rgba(0,0,0,0); +} + +.logo-mark { + border-radius: 1rem; + font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif!important; + font-weight: 700 !important; + letter-spacing: -1.5px; + border: 6px solid #212529; + + font-size: 2.5rem; + line-height: 1.2; + + user-select: none; + color: #fff !important; + text-decoration: none !important; + background: #212529; + + @media (min-width: 768px) { + font-size: 4.5rem; + } + + &-sm { + font-size: 16px !important; + border-width: 3px; + border-radius: 10px; + letter-spacing: -1px; + background: #212529; + } +} + +.display-4.font-weight-bold { + letter-spacing: -0.3px; + text-transform: uppercase; + + @media (min-width: 768px) { + letter-spacing: -3px; + } + + a { + color: #d1d5db; + text-decoration: underline; + } +} + +.display-4 { + font-size: 1.5rem; + + @media (min-width: 768px) { + font-size: 3.5rem; + } +} + +.btn-primary { + background-color: #3B82F6; +} + +.card-columns { + -moz-column-count: 3; + column-count: 3; + -moz-column-gap: 0px; + column-gap: 0px; + orphans: 1; + widows: 1; +} + +.portfolio-settings { + .nav-pills { + .nav-item { + &.disabled { + span { + pointer-events: none; + color: #3f3f46; + } + } + } + + .nav-link { + font-size: 15px; + color: #9ca3af; + font-weight: 400; + + &.active { + color: #fff; + background-image: linear-gradient(to right, #4f46e5 0%, #2F80ED 51%, #4f46e5 100%); + background-size: 200% auto; + font-weight: 100; + transition: 0.5s; + + &:hover { + background-position: right center; + } + } + } + } + + .card { + &-header { + background-color: #000; + border: 1px solid var(--dark); + font-size: 14px; + font-weight: 400; + text-transform: uppercase; + color: var(--muted); + } + + .list-group-item { + background: transparent; + } + } + + .custom-select { + border-radius: 10px; + font-weight: 700; + padding-left: 20px; + color: #fff; + background: #000 url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right 0.75rem center/8px 10px no-repeat; + border-color: var(--dark); + } + + .selected-badge { + width: 26px; + height: 26px; + display: flex; + border-radius: 26px; + background-color: #0284c7; + justify-content: center; + align-items: center; + font-size: 14px; + font-weight: 700; + color: #fff; + border: 2px solid #fff; + } +} + +.slide-fade-enter-active { + transition: all .3s ease; +} + +.slide-fade-leave-active { + transition: all .3s cubic-bezier(1.0, 1.0); +} + +.slide-fade-enter, .slide-fade-leave-to { + transform: translateX(10px); + opacity: 0; +} diff --git a/resources/views/layouts/partial/nav.blade.php b/resources/views/layouts/partial/nav.blade.php index 7cff72c7e..1d89902bf 100644 --- a/resources/views/layouts/partial/nav.blade.php +++ b/resources/views/layouts/partial/nav.blade.php @@ -1,6 +1,6 @@