Преглед на файлове

New landing page design

Daniel Supernault преди 2 години
родител
ревизия
09c0032b39

+ 45 - 0
app/Http/Controllers/LandingController.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Profile;
+use App\Services\AccountService;
+use App\Http\Resources\DirectoryProfile;
+
+class LandingController extends Controller
+{
+    public function directoryRedirect(Request $request)
+    {
+    	if($request->user()) {
+    		return redirect('/');
+    	}
+
+    	abort_if(config_cache('landing.show_directory') != 1, 404);
+
+    	return view('site.index');
+    }
+
+    public function exploreRedirect(Request $request)
+    {
+    	if($request->user()) {
+    		return redirect('/');
+    	}
+
+    	abort_if(config_cache('landing.show_explore_feed') != 1, 404);
+
+    	return view('site.index');
+    }
+
+    public function getDirectoryApi(Request $request)
+    {
+    	abort_if(config_cache('landing.show_directory') != 1, 404);
+
+    	return DirectoryProfile::collection(
+    		Profile::whereNull('domain')
+    		->whereIsSuggestable(true)
+    		->orderByDesc('updated_at')
+    		->cursorPaginate(20)
+    	);
+    }
+}

+ 37 - 0
app/Http/Resources/DirectoryProfile.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+use Cache;
+use App\Services\AccountService;
+
+class DirectoryProfile extends JsonResource
+{
+	/**
+	 * Transform the resource into an array.
+	 *
+	 * @param  \Illuminate\Http\Request  $request
+	 * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
+	 */
+	public function toArray($request)
+	{
+		$account = AccountService::get($this->id, true);
+		if(!$account) {
+			return [];
+		}
+
+		$url = url($this->username);
+		return [
+			'id' => $this->id,
+			'name' => $this->name,
+			'username' => $this->username,
+			'url' => $url,
+			'avatar' => $account['avatar'],
+			'following_count' => $account['following_count'],
+			'followers_count' => $account['followers_count'],
+			'statuses_count' => $account['statuses_count'],
+			'bio' => $account['note_text']
+		];
+	}
+}

+ 104 - 0
app/Services/LandingService.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace App\Services;
+
+use App\Util\ActivityPub\Helpers;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Redis;
+use App\Status;
+use App\User;
+use App\Services\AccountService;
+
+class LandingService
+{
+	public static function get($json = true)
+	{
+		$activeMonth = Cache::remember('api:nodeinfo:am', 172800, function() {
+			return User::select('last_active_at')
+				->where('last_active_at', '>', now()->subMonths(1))
+				->orWhere('created_at', '>', now()->subMonths(1))
+				->count();
+		});
+
+		$totalUsers = Cache::remember('api:nodeinfo:users', 43200, function() {
+			return User::count();
+		});
+
+		$postCount = Cache::remember('api:nodeinfo:statuses', 21600, function() {
+			return Status::whereLocal(true)->count();
+		});
+
+		$contactAccount = Cache::remember('api:v1:instance-data:contact', 604800, function () {
+			$admin = User::whereIsAdmin(true)->first();
+			return $admin && isset($admin->profile_id) ?
+				AccountService::getMastodon($admin->profile_id, true) :
+				null;
+		});
+
+		$rules = Cache::remember('api:v1:instance-data:rules', 604800, function () {
+			return config_cache('app.rules') ?
+				collect(json_decode(config_cache('app.rules'), true))
+				->map(function($rule, $key) {
+					$id = $key + 1;
+					return [
+						'id' => "{$id}",
+						'text' => $rule
+					];
+				})
+				->toArray() : [];
+		});
+
+		$res = [
+			'name' => config_cache('app.name'),
+			'url' => config_cache('app.url'),
+			'domain' => config('pixelfed.domain.app'),
+			'show_directory' => config_cache('landing.show_directory') == 1,
+			'show_explore_feed' => config_cache('landing.show_explore_feed') == 1,
+			'open_registration' => config_cache('pixelfed.open_registration') == 1,
+			'version' => config('pixelfed.version'),
+			'about' => [
+				'banner_image' => config_cache('app.banner_image') ?? url('/storage/headers/default.jpg'),
+				'short_description' => config_cache('app.short_description'),
+				'description' => config_cache('app.description'),
+			],
+			'stats' => [
+				'active_users' => (int) $activeMonth,
+				'posts_count' => (int) $postCount,
+				'total_users' => (int) $totalUsers
+			],
+			'contact' => [
+				'account' => $contactAccount,
+				'email' => config('instance.email')
+			],
+			'rules' => $rules,
+			'uploader' => [
+				'max_photo_size' => (int) (config('pixelfed.max_photo_size') * 1024),
+				'max_caption_length' => (int) config('pixelfed.max_caption_length'),
+				'max_altext_length' => (int) config('pixelfed.max_altext_length', 150),
+				'album_limit' => (int) config_cache('pixelfed.max_album_length'),
+				'image_quality' => (int) config_cache('pixelfed.image_quality'),
+				'max_collection_length' => (int) config('pixelfed.max_collection_length', 18),
+				'optimize_image' => (bool) config('pixelfed.optimize_image'),
+				'optimize_video' => (bool) config('pixelfed.optimize_video'),
+				'media_types' => config_cache('pixelfed.media_types'),
+			],
+			'features' => [
+				'federation' => config_cache('federation.activitypub.enabled'),
+				'timelines' => [
+					'local' => true,
+					'network' => (bool) config('federation.network_timeline'),
+				],
+				'mobile_apis' => (bool) config_cache('pixelfed.oauth_enabled'),
+				'stories' => (bool) config_cache('instance.stories.enabled'),
+				'video'	=> Str::contains(config_cache('pixelfed.media_types'), 'video/mp4'),
+			]
+		];
+
+		if($json) {
+			return json_encode($res);
+		}
+
+		return $res;
+	}
+}

+ 139 - 0
resources/assets/components/landing/Directory.vue

@@ -0,0 +1,139 @@
+<template>
+	<div class="landing-directory-component">
+		<section class="page-wrapper">
+			<div class="container container-compact">
+				<div class="card bg-bluegray-900" style="border-radius: 10px;">
+					<div class="card-header bg-bluegray-800 nav-menu" style="border-top-left-radius: 10px; border-top-right-radius: 10px;">
+						<ul class="nav justify-content-around">
+						  <li class="nav-item">
+							<router-link to="/" class="nav-link">About</router-link>
+							</li>
+							<li v-if="config.show_directory" class="nav-item">
+								<router-link to="/web/directory" class="nav-link">Directory</router-link>
+							</li>
+							<li v-if="config.show_explore_feed" class="nav-item">
+								<router-link to="/web/explore" class="nav-link">Explore</router-link>
+							</li>
+						</ul>
+					</div>
+
+					<div class="card-body">
+						<div class="py-3">
+							<p class="lead text-center">Discover accounts and people</p>
+						</div>
+
+						<div v-if="loading" class="d-flex justify-content-center align-items-center" style="min-height: 500px;">
+							<b-spinner />
+						</div>
+
+						<div v-else class="feed-list">
+							<user-card
+								v-for="account in feed"
+								:key="account.id"
+								:account="account" />
+
+							<intersect v-if="canLoadMore && !isEmpty" @enter="enterIntersect">
+								<div class="d-flex justify-content-center pt-5 pb-3">
+									<b-spinner v-if="isLoadingMore" />
+								</div>
+							</intersect>
+						</div>
+
+						<div v-if="isEmpty">
+							<div class="card card-body bg-bluegray-800">
+								<div class="d-flex justify-content-center align-items-center flex-column py-5">
+									<i class="fal fa-clock fa-6x text-bluegray-500"></i>
+									<p class="lead font-weight-bold mt-3 mb-0">Nothing to show yet! Check back later.</p>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+
+			<footer-component />
+		</section>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import UserCard from './partials/UserCard';
+	import Intersect from 'vue-intersect';
+
+	export default {
+		components: {
+			"user-card": UserCard,
+			"intersect": Intersect,
+		},
+
+		data() {
+			return {
+				loading: true,
+				config: window.pfl,
+				pagination: undefined,
+				feed: [],
+				isEmpty: false,
+				canLoadMore: false,
+				isIntersecting: false,
+				isLoadingMore: false
+			}
+		},
+
+		beforeMount() {
+			if(this.config.show_directory == false) {
+				this.$router.push('/');
+			}
+		},
+
+		mounted() {
+			this.init();
+		},
+
+		methods: {
+			init() {
+				axios.get('/api/landing/v1/directory')
+				.then(res => {
+					if(!res.data.data.length) {
+						this.isEmpty = true;
+					}
+					this.feed = res.data.data;
+					this.pagination = {...res.data.links, ...res.data.meta};
+				})
+				.finally(() => {
+					this.canLoadMore = true;
+					this.$nextTick(() => {
+						this.loading = false;
+					})
+				})
+			},
+
+			enterIntersect(e) {
+				if(this.isIntersecting || !this.pagination.next_cursor) {
+					return;
+				}
+				this.isIntersecting = true;
+				this.isLoadingMore = true;
+
+				axios.get('/api/landing/v1/directory', {
+					params: {
+						cursor: this.pagination.next_cursor
+					}
+				})
+				.then(res => {
+					this.feed.push(...res.data.data);
+					this.pagination = {...res.data.links, ...res.data.meta};
+				})
+				.finally(() => {
+					if(this.pagination.next_cursor) {
+						this.canLoadMore = true;
+					} else {
+						this.canLoadMore = false;
+					}
+					this.isLoadingMore = false;
+					this.isIntersecting = false;
+				});
+				console.log(e);
+			}
+		}
+	}
+</script>

+ 119 - 0
resources/assets/components/landing/Explore.vue

@@ -0,0 +1,119 @@
+<template>
+	<div class="landing-explore-component">
+		<section class="page-wrapper">
+			<div class="container container-compact">
+				<div class="card bg-bluegray-900" style="border-radius: 10px;">
+					<div class="card-header bg-bluegray-800 nav-menu" style="border-top-left-radius: 10px; border-top-right-radius: 10px;">
+						<ul class="nav justify-content-around">
+						  <li class="nav-item">
+							<router-link to="/" class="nav-link">About</router-link>
+							</li>
+							<li v-if="config.show_directory" class="nav-item">
+								<router-link to="/web/directory" class="nav-link">Directory</router-link>
+							</li>
+							<li v-if="config.show_explore_feed" class="nav-item">
+								<router-link to="/web/explore" class="nav-link">Explore</router-link>
+							</li>
+						</ul>
+					</div>
+
+					<div class="card-body">
+						<div class="py-3">
+							<p class="lead text-center">Explore trending posts</p>
+						</div>
+
+						<div v-if="loading" class="d-flex justify-content-center align-items-center" style="min-height: 500px;">
+							<b-spinner />
+						</div>
+
+						<div v-else class="feed-list">
+							<post-card
+								v-for="post in feed"
+								:key="post.id"
+								:post="post"
+								:range="ranges[rangeIndex]" />
+						</div>
+					</div>
+				</div>
+			</div>
+
+			<footer-component />
+		</section>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import PostCard from './partials/PostCard';
+
+	export default {
+		components: {
+			"post-card": PostCard
+		},
+
+		data() {
+			return {
+				loading: true,
+				config: window.pfl,
+				isFetching: false,
+				range: 'daily',
+				ranges: ['daily', 'monthly', 'yearly'],
+				rangeIndex: 0,
+				feed: [],
+			}
+		},
+
+		beforeMount() {
+			if(this.config.show_explore_feed == false) {
+				this.$router.push('/');
+			}
+		},
+
+		mounted() {
+			this.init();
+		},
+
+		methods: {
+			init() {
+				axios.get('/api/pixelfed/v2/discover/posts/trending?range=daily')
+				.then(res => {
+					if(res && res.data.length > 3) {
+						this.feed = res.data;
+						this.loading = false;
+					} else {
+						this.rangeIndex++;
+						this.fetchTrending();
+					}
+				})
+			},
+
+			fetchTrending() {
+				if(this.isFetching || this.rangeIndex >= 3) {
+					return;
+				}
+				this.isFetching = true;
+
+				axios.get('/api/pixelfed/v2/discover/posts/trending', {
+					params: {
+						range: this.ranges[this.rangeIndex]
+					}
+				})
+				.then(res => {
+					if(res && res.data.length) {
+						if(this.rangeIndex == 2 && res.data.length > 3) {
+							this.feed = res.data;
+							this.loading = false;
+						} else {
+							this.rangeIndex++;
+							this.isFetching = false;
+							this.fetchTrending();
+						}
+					} else {
+						this.rangeIndex++;
+						this.isFetching = false;
+						this.fetchTrending();
+					}
+				})
+			}
+		}
+	}
+</script>

+ 231 - 0
resources/assets/components/landing/Index.vue

@@ -0,0 +1,231 @@
+<template>
+	<div class="landing-index-component">
+		<section class="page-wrapper">
+			<div class="container container-compact">
+				<div class="card bg-bluegray-900" style="border-radius: 10px;">
+					<div class="card-header bg-bluegray-800 nav-menu" style="border-top-left-radius: 10px; border-top-right-radius: 10px;">
+						<ul class="nav justify-content-around">
+						  <li class="nav-item">
+							<router-link to="/" class="nav-link">About</router-link>
+							</li>
+							<li v-if="config.show_directory" class="nav-item">
+								<router-link to="/web/directory" class="nav-link">Directory</router-link>
+							</li>
+							<li v-if="config.show_explore_feed" class="nav-item">
+								<router-link to="/web/explore" class="nav-link">Explore</router-link>
+							</li>
+						</ul>
+					</div>
+
+					<div class="card-img-top p-2">
+						<img
+							:src="config.about.banner_image"
+							class="img-fluid rounded"
+							style="width: 100%;max-height: 200px;object-fit: cover;"
+							alt="Server banner image"
+							onerror="this.src='/storage/headers/default.jpg';this.onerror=null;">
+					</div>
+
+					<div class="card-body">
+						<div class="server-header">
+							<p class="server-header-domain">{{ config.domain }}</p>
+							<p class="server-header-attribution">
+								Decentralized photo sharing social media powered by <a href="https://pixelfed.org">Pixelfed</a>
+							</p>
+						</div>
+
+						<div class="server-stats">
+							<div class="list-group">
+								<div class="list-group-item bg-transparent">
+									<p class="stat-value">{{ formatCount(config.stats.posts_count) }}</p>
+									<p class="stat-label">Posts</p>
+								</div>
+								<div class="list-group-item bg-transparent">
+									<p class="stat-value">{{ formatCount(config.stats.active_users) }}</p>
+									<p class="stat-label">Active Users</p>
+								</div>
+								<div class="list-group-item bg-transparent">
+									<p class="stat-value">{{ formatCount(config.stats.total_users) }}</p>
+									<p class="stat-label">Total Users</p>
+								</div>
+							</div>
+						</div>
+
+						<div class="server-admin">
+							<div class="list-group">
+								<div v-if="config.contact.account" class="list-group-item bg-transparent">
+									<p class="item-label">Managed By</p>
+									<a :href="config.contact.account.url" class="admin-card">
+										<div class="d-flex">
+											<img :src="config.contact.account.avatar" onerror="this.src='/storage/avatars/default.jpg';this.onerror=null;" width="45" height="45" class="avatar">
+
+											<div class="user-info">
+												<p class="display-name">{{ config.contact.account.display_name }}</p>
+												<p class="username">&commat;{{ config.contact.account.username }}</p>
+											</div>
+										</div>
+									</a>
+								</div>
+
+								<div v-if="config.contact.email" class="list-group-item bg-transparent">
+									<p class="item-label">Contact</p>
+									<a :href="`mailto:${config.contact.email}`" class="admin-email">{{ config.contact.email }}</a>
+								</div>
+							</div>
+						</div>
+
+						<div class="accordion" id="accordion">
+						  <div class="card bg-bluegray-700">
+						    <div class="card-header bg-bluegray-800" id="headingOne">
+						      <h2 class="mb-0">
+						        <button class="btn btn-link btn-block" type="button" data-toggle="collapse" data-target="#collapseOne" aria-controls="collapseOne" @click="toggleAccordion(0)">
+						        	<span class="text-white h5">
+							        	<i class="far fa-info-circle mr-2 text-muted"></i>
+							          	About
+						        	</span>
+						        	<i class="far" :class="[ accordionTab === 0 ? 'fa-chevron-left text-primary': 'fa-chevron-down']"></i>
+						        </button>
+						      </h2>
+						    </div>
+
+						    <div id="collapseOne" class="collapse" aria-labelledby="headingOne" data-parent="#accordion">
+						      <div class="card-body about-text">
+						        <p v-html="config.about.description"></p>
+						      </div>
+						    </div>
+						  </div>
+
+						  <div class="card bg-bluegray-700">
+						    <div class="card-header bg-bluegray-800" id="headingTwo">
+						      <h2 class="mb-0">
+						        <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse" data-target="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo" @click="toggleAccordion(1)">
+						        	<span class="text-white h5">
+							        	<i class="far fa-list mr-2 text-muted"></i>
+						          		Server Rules
+						          	</span>
+						        	<i class="far" :class="[ accordionTab === 1 ? 'fa-chevron-left text-primary': 'fa-chevron-down']"></i>
+						        </button>
+						      </h2>
+						    </div>
+						    <div id="collapseTwo" class="collapse" aria-labelledby="headingTwo" data-parent="#accordion">
+						      <div class="card-body">
+						        <div class="list-group list-group-rules">
+						        	<div v-for="rule in config.rules" class="list-group-item bg-bluegray-900">
+						        		<div class="rule-id">{{ rule.id }}</div>
+						        		<div class="rule-text">{{ rule.text }}</div>
+						        	</div>
+						        </div>
+						      </div>
+						    </div>
+						  </div>
+
+						  <div class="card bg-bluegray-700">
+						    <div class="card-header bg-bluegray-800" id="headingThree">
+						      <h2 class="mb-0">
+						        <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse" data-target="#collapseThree" aria-expanded="false" aria-controls="collapseThree" @click="toggleAccordion(2)">
+						        	<span class="text-white h5">
+							        	<i class="far fa-sparkles mr-2 text-muted"></i>
+						          		Supported Features
+						          	</span>
+						        	<i class="far" :class="[ accordionTab === 2 ? 'fa-chevron-left text-primary': 'fa-chevron-down']"></i>
+						        </button>
+						      </h2>
+						    </div>
+						    <div id="collapseThree" class="collapse" aria-labelledby="headingThree" data-parent="#accordion">
+						      <div class="card-body card-features">
+						      	<div class="card-features-cloud">
+						      		<div class="badge badge-success"><i class="far fa-check-circle"></i> Photo Posts</div>
+						      		<div class="badge badge-success"><i class="far fa-check-circle"></i> Photo Albums</div>
+						      		<div class="badge badge-success"><i class="far fa-check-circle"></i> Photo Filters</div>
+						      		<div class="badge badge-success"><i class="far fa-check-circle"></i> Collections</div>
+						      		<div class="badge badge-success"><i class="far fa-check-circle"></i> Comments</div>
+						      		<div class="badge badge-success"><i class="far fa-check-circle"></i> Hashtags</div>
+						      		<div class="badge badge-success"><i class="far fa-check-circle"></i> Likes</div>
+						      		<div class="badge badge-success"><i class="far fa-check-circle"></i> Notifications</div>
+						      		<div class="badge badge-success"><i class="far fa-check-circle"></i> Shares</div>
+						      	</div>
+
+						      	<div class="py-3">
+						      		<p class="lead">
+						      			<span>You can share up to <span class="font-weight-bold">{{ config.uploader.album_limit }}</span> photos*</span>
+						      			<span v-if="config.features.video">or <span class="font-weight-bold">1</span> video*</span>
+						      			<span>at a time with a max caption length of <span class="font-weight-bold">{{ config.uploader.max_caption_length }}</span> characters.</span>
+									</p>
+									<p class="small opacity-50">* - Maximum file size is {{ formatBytes(config.uploader.max_photo_size) }}</p>
+						      	</div>
+
+						        <div class="list-group list-group-features">
+						        	<div class="list-group-item bg-bluegray-900">
+						        		<div class="feature-label">Federation</div>
+						        		<i class="far fa-lg" :class="[config.features.federation ? 'fa-check-circle' : 'fa-times-circle' ]"></i>
+						        	</div>
+
+						        	<div class="list-group-item bg-bluegray-900">
+						        		<div class="feature-label">Mobile App Support</div>
+						        		<i class="far fa-lg" :class="[config.features.mobile_apis ? 'fa-check-circle' : 'fa-times-circle' ]"></i>
+						        	</div>
+
+						        	<div class="list-group-item bg-bluegray-900">
+						        		<div class="feature-label">Stories</div>
+						        		<i class="far fa-lg" :class="[config.features.stories ? 'fa-check-circle' : 'fa-times-circle' ]"></i>
+						        	</div>
+
+						        	<div class="list-group-item bg-bluegray-900">
+						        		<div class="feature-label">Videos</div>
+						        		<i class="far fa-lg" :class="[config.features.video ? 'fa-check-circle' : 'fa-times-circle' ]"></i>
+						        	</div>
+						        </div>
+						      </div>
+						    </div>
+						  </div>
+						</div>
+					</div>
+				</div>
+			</div>
+
+			<footer-component />
+		</section>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		data() {
+			return {
+				config: window.pfl,
+				accordionTab: undefined
+			}
+		},
+
+		methods: {
+			toggleAccordion(idx) {
+				if(this.accordionTab == idx) {
+					this.accordionTab = undefined;
+					return;
+				}
+				this.accordionTab = idx;
+			},
+
+			formatCount(val) {
+				if(!val) {
+					return 0;
+				}
+
+				return val.toLocaleString('en-CA', { compactDisplay: "short", notation: "compact"});
+			},
+
+			formatBytes(bytes, unit = 'megabyte') {
+				const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte'];
+				const navigatorLocal = navigator.languages && navigator.languages.length >= 0 ? navigator.languages[0] : 'en-US';
+				const unitIndex = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
+				return Intl.NumberFormat(navigatorLocal, {
+				    style: 'unit',
+					unit : units[unitIndex],
+					useGrouping: false,
+					maximumFractionDigits: 0,
+					roundingMode: 'ceil'
+				}).format(bytes / (1024 ** unitIndex))
+			}
+		}
+	}
+</script>

+ 14 - 0
resources/assets/components/landing/NotFound.vue

@@ -0,0 +1,14 @@
+<template>
+	<div class="landing-index-component h-100">
+		<section class="page-wrapper h-100 d-flex flex-grow-1 justify-content-center align-items-center">
+			<div class="d-flex flex-column align-items-center gap-3">
+				<i class="fal fa-exclamation-triangle fa-5x text-bluegray-500"></i>
+				<div class="text-center">
+					<h2>404 - Not Found</h2>
+					<p class="lead">The page you are looking for does not exist.</p>
+				</div>
+				<a class="btn btn-outline-light btn-lg rounded-pill px-4" href="/">Go back home</a>
+			</div>
+		</section>
+	</div>
+</template>

+ 93 - 0
resources/assets/components/landing/partials/PostCard.vue

@@ -0,0 +1,93 @@
+<template>
+	<div class="timeline-status-component">
+		<div class="card bg-bluegray-800 landing-post-card" style="border-radius: 15px;">
+			<div class="card-header border-0 bg-bluegray-700" style="border-top-left-radius: 15px;border-top-right-radius: 15px;">
+				<div class="media align-items-center">
+					<a :href="post.account.url" class="mr-2">
+						<img :src="post.account.avatar" style="border-radius:30px;" width="30" height="30" onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;">
+					</a>
+
+					<div class="media-body d-flex justify-content-between align-items-center">
+						<p class="font-weight-bold username mb-0">
+							<a :href="post.account.url" class="text-white">&commat;{{ post.account.username }}</a>
+						</p>
+
+						<p class="font-weight-bold mb-0">
+							<a v-if="range === 'daily'" :href="post.url" class="text-bluegray-500">Posted {{ timeago(post.created_at) }} ago</a>
+							<a v-else :href="post.url" class="text-bluegray-400">View Post</a>
+						</p>
+					</div>
+				</div>
+			</div>
+			<div class="card-body m-0 p-0">
+				<post-content :status="post" />
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import PostContent from './../../partials/post/PostContent';
+
+	export default {
+		props: [
+			'post',
+			'range'
+		],
+
+		components: {
+			'post-content': PostContent,
+		},
+
+		methods: {
+			timestampToAgo(ts) {
+				let date = Date.parse(ts);
+				let seconds = Math.floor((new Date() - date) / 1000);
+				let interval = Math.floor(seconds / 63072000);
+				if (interval < 0) {
+					return "0s";
+				}
+				if (interval >= 1) {
+					return interval + "y";
+				}
+				interval = Math.floor(seconds / 604800);
+				if (interval >= 1) {
+					return interval + "w";
+				}
+				interval = Math.floor(seconds / 86400);
+				if (interval >= 1) {
+					return interval + "d";
+				}
+				interval = Math.floor(seconds / 3600);
+				if (interval >= 1) {
+					return interval + "h";
+				}
+				interval = Math.floor(seconds / 60);
+				if (interval >= 1) {
+					return interval + "m";
+				}
+				return Math.floor(seconds) + "s";
+			},
+
+			timeago(ts) {
+				let short = this.timestampToAgo(ts);
+				return short;
+				if(
+					short.endsWith('s') ||
+					short.endsWith('m') ||
+					short.endsWith('h')
+				) {
+					return short;
+				}
+				const intl = new Intl.DateTimeFormat(undefined, {
+					year:  'numeric',
+					month: 'short',
+					day:   'numeric',
+					hour: 'numeric',
+					minute: 'numeric'
+				});
+				return intl.format(new Date(ts));
+			},
+		}
+	}
+</script>

+ 56 - 0
resources/assets/components/landing/partials/UserCard.vue

@@ -0,0 +1,56 @@
+<template>
+	<div class="card bg-bluegray-800 landing-user-card">
+		<div class="card-body">
+			<div class="d-flex" style="gap: 15px;">
+				<div class="flex-shrink-1">
+					<a :href="account.url">
+						<img class="rounded-circle" :src="account.avatar" onerror="this.src='/storage/avatars/default.jpg';this.onerror=null;" width="50" height="50">
+					</a>
+				</div>
+
+				<div class="flex-grow-1">
+					<div v-if="account.name" class="display-name">
+						<a :href="account.url">{{ account.name }}</a>
+					</div>
+					<p class="username">
+						<a :href="account.url">&commat;{{ account.username }}</a>
+					</p>
+
+					<div class="user-stats">
+						<div class="user-stats-item">{{ formatCount(account.statuses_count) }} Posts</div>
+						<div class="user-stats-item">{{ formatCount(account.followers_count) }} Followers</div>
+						<div class="user-stats-item">{{ formatCount(account.following_count) }} Following</div>
+					</div>
+
+					<div v-if="account.bio" class="user-bio">
+						<p class="small text-bluegray-400 mb-0">{{ truncate(account.bio) }}</p>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: ['account'],
+
+		methods: {
+			formatCount(val) {
+				if(!val) {
+					return 0;
+				}
+
+				return val.toLocaleString('en-CA', { compactDisplay: "short", notation: "compact"});
+			},
+
+			truncate(val, limit = 120) {
+				if(!val || val.length < limit) {
+					return val;
+				}
+
+				return val.slice(0, limit) + '...'
+			}
+		}
+	}
+</script>

+ 36 - 0
resources/assets/components/landing/sections/footer.vue

@@ -0,0 +1,36 @@
+<template>
+	<div class="py-5">
+		<p class="text-center text-uppercase font-weight-bold small text-justify">
+			<a href="/site/help" class="text-bluegray-400 p-2">Help</a>
+			<span class="mx-2 text-muted">·</span>
+			<a href="/site/terms" class="text-bluegray-400 p-2">Terms</a>
+			<span class="mx-2 text-muted">·</span>
+			<a href="/site/privacy" class="text-bluegray-400 p-2">Privacy</a>
+			<span class="mx-2 text-muted">·</span>
+			<a href="https://pixelfed.org/mobile-apps" class="text-bluegray-400 p-2" target="_blank">Mobile Apps</a>
+		</p>
+		<p class="text-center text-bluegray-500 small mb-0">
+			<span class="text-bluegray-500">© {{ getYear() }} {{config.domain}}</span>
+			<span class="mx-2 text-muted">·</span>
+			<a href="https://pixelfed.org" class="text-bluegray-500 font-weight-bold">Powered by Pixelfed</a>
+			<span class="mx-2 text-muted">·</span>
+			<span class="text-bluegray-500">v{{config.version}}</span>
+		</p>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		data() {
+			return {
+				config: window.pfl
+			}
+		},
+
+		methods: {
+			getYear() {
+				return (new Date().getFullYear());
+			}
+		}
+	}
+</script>

+ 33 - 0
resources/assets/components/landing/sections/nav.vue

@@ -0,0 +1,33 @@
+<template>
+	<nav class="navbar navbar-expand-lg navbar-dark fixed-top">
+		<div class="container" style="max-width: 600px;">
+			<router-link to="/" class="navbar-brand">
+				<img src="/img/pixelfed-icon-color.svg" width="40" height="40" alt="Logo">
+				<span class="mr-3">{{ name }}</span>
+			</router-link>
+			<ul class="navbar-nav mr-auto">
+			</ul>
+			<div class="my-2 my-lg-0">
+				<a class="btn btn-outline-light btn-sm rounded-pill font-weight-bold px-4" href="/login">Login</a>
+
+				<a v-if="config.open_registration" class="ml-2 btn btn-primary btn-primary-alt btn-sm rounded-pill font-weight-bold px-4" href="/register">Sign up</a>
+			</div>
+		</div>
+	</nav>
+</template>
+
+<script type="text/javascript">
+	export default {
+		data() {
+			return {
+				config: window.pfl,
+				name: window.pfl.name,
+			}
+		},
+		mounted() {
+			$(window).scroll(function(){
+				$('nav').toggleClass('bg-black', $(this).scrollTop() > 20);
+			});
+		}
+	}
+</script>

+ 295 - 0
resources/assets/js/landing.js

@@ -0,0 +1,295 @@
+require('./polyfill');
+import Vue from 'vue';
+window.Vue = Vue;
+import VueRouter from "vue-router";
+import Vuex from "vuex";
+import { sync } from "vuex-router-sync";
+import BootstrapVue from 'bootstrap-vue'
+import InfiniteLoading from 'vue-infinite-loading';
+import Loading from 'vue-loading-overlay';
+import VueTimeago from 'vue-timeago';
+import VueCarousel from 'vue-carousel';
+import VueBlurHash from 'vue-blurhash';
+import VueMasonry from 'vue-masonry-css';
+import VueI18n from 'vue-i18n';
+window.pftxt = require('twitter-text');
+import 'vue-blurhash/dist/vue-blurhash.css'
+window.filesize = require('filesize');
+import swal from 'sweetalert';
+window._ = require('lodash');
+window.Popper = require('popper.js').default;
+window.pixelfed = window.pixelfed || {};
+window.$ = window.jQuery = require('jquery');
+require('bootstrap');
+window.axios = require('axios');
+window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
+require('readmore-js');
+window.blurhash = require("blurhash");
+
+$('[data-toggle="tooltip"]').tooltip()
+let token = document.head.querySelector('meta[name="csrf-token"]');
+if (token) {
+	window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
+} else {
+	console.error('CSRF token not found.');
+}
+
+Vue.use(VueRouter);
+Vue.use(Vuex);
+Vue.use(VueBlurHash);
+Vue.use(VueCarousel);
+Vue.use(BootstrapVue);
+Vue.use(InfiniteLoading);
+Vue.use(Loading);
+Vue.use(VueMasonry);
+Vue.use(VueI18n);
+Vue.use(VueTimeago, {
+  name: 'Timeago',
+  locale: 'en'
+});
+
+Vue.component(
+	'photo-presenter',
+	require('./components/presenter/PhotoPresenter.vue').default
+);
+
+Vue.component(
+	'video-presenter',
+	require('./components/presenter/VideoPresenter.vue').default
+);
+
+Vue.component(
+	'photo-album-presenter',
+	require('./components/presenter/PhotoAlbumPresenter.vue').default
+);
+
+Vue.component(
+	'video-album-presenter',
+	require('./components/presenter/VideoAlbumPresenter.vue').default
+);
+
+Vue.component(
+	'mixed-album-presenter',
+	require('./components/presenter/MixedAlbumPresenter.vue').default
+);
+
+Vue.component(
+	'navbar',
+	require('./../components/landing/sections/nav.vue').default
+);
+
+Vue.component(
+	'footer-component',
+	require('./../components/landing/sections/footer.vue').default
+);
+
+import IndexComponent from "./../components/landing/Index.vue";
+import DirectoryComponent from "./../components/landing/Directory.vue";
+import ExploreComponent from "./../components/landing/Explore.vue";
+import NotFoundComponent from "./../components/landing/NotFound.vue";
+
+const router = new VueRouter({
+	mode: "history",
+	linkActiveClass: "",
+	linkExactActiveClass: "active",
+
+	routes: [
+		{
+			path: "/",
+			component: IndexComponent
+		},
+		{
+			path: "/web/directory",
+			component: DirectoryComponent
+		},
+		{
+			path: "/web/explore",
+			component: ExploreComponent
+		},
+		{
+			path: "/*",
+			component: NotFoundComponent,
+			props: true
+		},
+	],
+
+	scrollBehavior(to, from, savedPosition) {
+		if (to.hash) {
+			return {
+				selector: `[id='${to.hash.slice(1)}']`
+			};
+		} else {
+			return { x: 0, y: 0 };
+		}
+	}
+});
+
+function lss(name, def) {
+	let key = 'pf_m2s.' + name;
+	let ls = window.localStorage;
+	if(ls.getItem(key)) {
+		let val = ls.getItem(key);
+		if(['pl', 'color-scheme'].includes(name)) {
+			return val;
+		}
+		return ['true', true].includes(val);
+	}
+	return def;
+}
+
+const store = new Vuex.Store({
+	state: {
+		version: 1,
+		hideCounts: true,
+		autoloadComments: false,
+		newReactions: false,
+		fixedHeight: false,
+		profileLayout: 'grid',
+		showDMPrivacyWarning: true,
+		relationships: {},
+		emoji: [],
+		colorScheme: lss('color-scheme', 'system'),
+	},
+
+	getters: {
+		getVersion: state => {
+			return state.version;
+		},
+
+		getHideCounts: state => {
+			return state.hideCounts;
+		},
+
+		getAutoloadComments: state => {
+			return state.autoloadComments;
+		},
+
+		getNewReactions: state => {
+			return state.newReactions;
+		},
+
+		getFixedHeight: state => {
+			return state.fixedHeight;
+		},
+
+		getProfileLayout: state => {
+			return state.profileLayout;
+		},
+
+		getRelationship: (state) => (id) => {
+			return state.relationships[id];
+		},
+
+		getCustomEmoji: state => {
+			return state.emoji;
+		},
+
+		getColorScheme: state => {
+			return state.colorScheme;
+		},
+
+		getShowDMPrivacyWarning: state => {
+			return state.showDMPrivacyWarning;
+		}
+	},
+
+	mutations: {
+		setVersion(state, value) {
+			state.version = value;
+		},
+
+		setHideCounts(state, value) {
+			localStorage.setItem('pf_m2s.hc', value);
+			state.hideCounts = value;
+		},
+
+		setAutoloadComments(state, value) {
+			localStorage.setItem('pf_m2s.ac', value);
+			state.autoloadComments = value;
+		},
+
+		setNewReactions(state, value) {
+			localStorage.setItem('pf_m2s.nr', value);
+			state.newReactions = value;
+		},
+
+		setFixedHeight(state, value) {
+			localStorage.setItem('pf_m2s.fh', value);
+			state.fixedHeight = value;
+		},
+
+		setProfileLayout(state, value) {
+			localStorage.setItem('pf_m2s.pl', value);
+			state.profileLayout = value;
+		},
+
+		updateRelationship(state, relationships) {
+			relationships.forEach((relationship) => {
+				Vue.set(state.relationships, relationship.id, relationship)
+			})
+		},
+
+		updateCustomEmoji(state, emojis) {
+			state.emoji = emojis;
+		},
+
+		setColorScheme(state, value) {
+			if(state.colorScheme == value) {
+				return;
+			}
+			localStorage.setItem('pf_m2s.color-scheme', value);
+			state.colorScheme = value;
+			const name = value == 'system' ? '' : (value == 'light' ? 'force-light-mode' : 'force-dark-mode');
+			document.querySelector("body").className = name;
+			if(name != 'system') {
+				const payload = name == 'force-dark-mode' ? { dark_mode: 'on' } : {};
+				axios.post('/settings/labs', payload);
+			}
+		},
+
+		setShowDMPrivacyWarning(state, value) {
+			localStorage.setItem('pf_m2s.dmpwarn', value);
+			state.showDMPrivacyWarning = value;
+		}
+	},
+});
+
+let i18nMessages = {
+	en: require('./i18n/en.json'),
+	ar: require('./i18n/ar.json'),
+	ca: require('./i18n/ca.json'),
+	de: require('./i18n/de.json'),
+	el: require('./i18n/el.json'),
+	es: require('./i18n/es.json'),
+	eu: require('./i18n/eu.json'),
+	fr: require('./i18n/fr.json'),
+	he: require('./i18n/he.json'),
+	gd: require('./i18n/gd.json'),
+	gl: require('./i18n/gl.json'),
+	id: require('./i18n/id.json'),
+	it: require('./i18n/it.json'),
+	ja: require('./i18n/ja.json'),
+	nl: require('./i18n/nl.json'),
+	pl: require('./i18n/pl.json'),
+	pt: require('./i18n/pt.json'),
+	ru: require('./i18n/ru.json'),
+	uk: require('./i18n/uk.json'),
+	vi: require('./i18n/vi.json'),
+};
+
+let locale = document.querySelector('html').getAttribute('lang');
+
+const i18n = new VueI18n({
+  locale: locale, // set locale
+  fallbackLocale: 'en',
+  messages: i18nMessages
+});
+
+sync(store, router);
+
+const App = new Vue({
+	el: '#content',
+	i18n,
+	router,
+	store
+});

+ 549 - 5
resources/assets/sass/landing.scss

@@ -2,12 +2,556 @@
 
 @import "fonts";
 @import "lib/fontawesome";
+@import "lib/inter";
+@import "lib/manrope";
 @import 'variables';
 @import '~bootstrap/scss/bootstrap';
 @import 'custom';
 
-.container.slim {
-	width: auto;
-	max-width: 680px;
-	padding: 0 15px;
-}
+body {
+	background: #080e2b;
+	font-family: 'Manrope', sans-serif;
+	color: #fff;
+}
+
+.bg-black {
+	background-color: #080e2b;
+	transition: background-color 0.3s ease;
+}
+
+.navbar {
+	padding-top: 20px;
+	padding-bottom: 20px;
+
+	&-brand {
+		display: flex;
+		align-items: center;
+		gap: 10px;
+
+		span {
+			font-weight: bold;
+			font-size: 24px;
+		}
+	}
+
+	.nav-link {
+		&.active {
+			font-weight: bold;
+		}
+	}
+
+	@include media-breakpoint-up(lg) {
+		.nav-link {
+			margin-right: 1rem !important;
+		}
+	}
+}
+
+.bg-bluegray {
+	&-700 {
+		background-color: #334155;
+	}
+
+	&-800 {
+		background-color: #1e293b;
+	}
+
+	&-900 {
+		background-color: #0f172a;
+	}
+}
+
+.text-bluegray {
+	&-400 {
+		color: #94a3b8;
+	}
+	&-500 {
+		color: #64748b;
+	}
+	&-600 {
+		color: #475569;
+	}
+}
+
+.page-wrapper {
+	position: relative;
+	padding-top: 3rem;
+	padding-bottom: 3rem;
+	min-height: 100vh !important;
+	background-color: #212529 !important;
+	background-image: url("/_landing/bg.jpg");
+	background-size: cover !important;
+	background-repeat: no-repeat !important;
+	background-position: center !important;
+}
+
+.container-compact {
+	max-width: 600px;
+	margin-top: 3rem;
+	padding-top: 3rem;
+	padding-left: 0.25rem;
+	padding-right: 0.25rem;
+
+	@media (min-width: 768px) {
+		padding-top: 0 !important;
+	}
+}
+
+.overflow-hidden {
+    overflow: hidden !important;
+}
+
+.bg-glass {
+	background: rgba(255, 255, 255, 0.05);
+	border-radius: 16px;
+	box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
+	backdrop-filter: blur(5px);
+	-webkit-backdrop-filter: blur(5px);
+	border: 1px solid rgba(255, 255, 255, 0.05);
+	margin-bottom: -1px;
+}
+
+.text-gradient-primary {
+  background: linear-gradient(to right, #6366f1, #8B5CF6, #D946EF);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+}
+
+.gap-3 {
+	gap: 3rem;
+}
+
+.btn-primary-alt {
+  border: none;
+  outline: none;
+  color: white;
+  position: relative;
+  z-index: 1;
+  cursor: pointer;
+  background: none;
+  text-shadow: 3px 3px 10px rgba(0,0,0,.45);
+  &:before, &:after {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    border-radius: 10em;
+    transform: translateX(-50%) translateY(-50%);
+    width: 105%;
+    height: 105%;
+    content: '';
+    z-index: -2;
+    background-size: 400% 400%;
+    background: linear-gradient(60deg, #f79533, #f37055, #ef4e7b, #a166ab, #5073b8, #1098ad, #07b39b, #6fba82);
+  }
+  &:before {
+    filter: blur(7px);
+    transition: all .25s ease;
+    animation: pulse 10s infinite ease;
+  }
+  &:after {
+    filter: blur(0.3px);
+  }
+  &:hover {
+    &:before {
+      width: 115%;
+      height: 115%;
+    }
+  }
+}
+
+.opacity-50 {
+	opacity: 50%;
+}
+
+.opacity-30 {
+	opacity: 30%;
+}
+
+.nav-menu {
+	border-bottom: 1px solid #334155;
+	.nav-link {
+		color: #94a3b8;
+		position: relative;
+		font-size: 12px;
+
+		@media(min-width: 768px) {
+			font-size: 16px;
+		}
+
+		&.active {
+			color: #ffffff;
+			font-weight: 600;
+		}
+
+		&.active:before,
+		&.active:after,
+		&.nav-item:hover:before,
+		&.nav-item:hover:after {
+			content: ' ';
+			position: absolute;
+			border: solid 10px transparent;
+			border-bottom: solid 0px transparent;
+			border-width: 10px;
+			bottom: -12px;
+			left: 50%;
+			margin-left: -10px;
+			border-color: transparent transparent #334155;
+		}
+
+		&.active:after,
+		&.nav-item:hover:after {
+			bottom: -14px;
+		    border-color: transparent transparent #0f172a;
+		}
+	}
+}
+
+.landing-index-component {
+	width: 100%;
+	overflow: hidden;
+
+	.logo {
+		margin-right: 10px;
+	}
+
+	h1 {
+		color: var(--light);
+		font-size: 4em;
+		font-weight: bold;
+		margin-bottom: 0;
+	}
+
+	p {
+		color: var(--light);
+	}
+
+	.server-header {
+		margin: 0 0 30px 0;
+
+		&-domain {
+			text-align: center;
+			font-size: 25px;
+			font-weight: 700;
+		}
+
+		&-attribution {
+			font-size: 16px;
+			text-align: center;
+			color: #94a3b8;
+			letter-spacing: 0.6px;
+
+			a {
+				color: #ffffff;
+				font-weight: 800;
+			}
+		}
+	}
+
+	.server-stats {
+		margin: 30px 0;
+
+		.list-group {
+			flex-direction: column;
+			border-color: #1e293b;
+
+			@media (min-width: 768px) {
+				flex-direction: row;
+
+				&-item {
+					border-color: #1e293b;
+					flex-grow: 1;
+					border-top-width: 1px;
+    				border-left-width: 0;
+
+    				&:first-child {
+    					border-left-width: 1px;
+    				}
+
+    				&:last-child {
+    					border-top-right-radius: 0.25rem;
+    					border-bottom-left-radius: 0;
+    				}
+				}
+			}
+
+			&-item {
+				border-color: #1e293b;
+			}
+		}
+
+		.stat-value {
+			font-size: 20px;
+			font-weight: 700;
+			color: #ffffff;
+			margin-bottom: 0;
+		}
+
+		.stat-label {
+			font-size: 12px;
+			font-weight: 700;
+			color: #64748b;
+			margin-bottom: 0;
+			text-transform: uppercase;
+			letter-spacing: 0.8px;
+		}
+	}
+
+	.server-admin {
+		margin: 30px 0;
+
+		.list-group {
+			flex-direction: column;
+			border-color: #1e293b;
+
+			@media (min-width: 768px) {
+				flex-direction: row;
+
+				&-item {
+					border-color: #1e293b;
+					flex-grow: 1;
+					border-top-width: 1px;
+    				border-left-width: 0;
+
+    				&:first-child {
+    					border-left-width: 1px;
+    				}
+
+    				&:last-child {
+    					border-top-right-radius: 0.25rem;
+    					border-bottom-left-radius: 0;
+    				}
+				}
+			}
+
+			&-item {
+				border-color: #1e293b;
+			}
+		}
+
+		.item-label {
+			color: #475569;
+			text-transform: uppercase;
+			font-weight: 500;
+			letter-spacing: 1px;
+		}
+
+		.admin-card {
+			text-decoration: none;
+
+			.d-flex {
+				gap: 10px;
+			}
+
+			.avatar {
+				border-radius: 6px;
+			}
+
+			.user-info {
+				.display-name {
+					color: #94a3b8;
+				}
+
+				.username {
+					font-weight: 700;
+				}
+
+				.display-name,
+				.username {
+					margin-bottom: 0;
+				}
+			}
+		}
+
+		.admin-email {
+			color: #ffffff;
+			font-size: 15px;
+			font-weight: 700;
+			text-decoration: none;
+		}
+	}
+
+	.accordion {
+		.btn-block {
+			display: flex;
+			justify-content: space-between;
+			align-items: center;
+			text-decoration: none;
+
+			.h5 {
+				margin-bottom: 0;
+			}
+
+			&:focus {
+				box-shadow: none;
+			}
+
+			.far {
+				color: #cbd5e1;
+			}
+
+			.text-white {
+				.far {
+					color: #94a3b8;
+				}
+			}
+		}
+
+		.about-text {
+			padding: 40px 24px;
+
+			p {
+				font-size: 17px;
+			}
+
+			p:last-child {
+				margin-bottom: 0;
+			}
+		}
+
+		.list-group-rules {
+			.list-group-item {
+				display: flex;
+				gap: 10px;
+				align-items: center;
+				border-color: #475569;
+
+				.rule-id {
+					color: #475569;
+					font-size: 20px;
+				}
+
+				.rule-text {
+					color: #fff;
+				}
+			}
+		}
+
+		.card-features {
+			&-cloud {
+				display: flex;
+				flex-wrap: wrap;
+				justify-content: center;
+				gap: 10px;
+				padding: 10px 5px;
+				margin-bottom: 20px;
+
+				.badge {
+					font-size: 13px;
+					font-weight: 400;
+					padding: 5px 10px;
+
+					&-success {
+						background: #86efac30;
+					}
+
+					.far {
+						margin-right: 5px;
+						color: #22c55e;
+					}
+				}
+			}
+
+			.list-group-features {
+				.list-group-item {
+					display: flex;
+					justify-content: space-between;
+					align-items: center;
+					border-color: #475569;
+
+					.feature-label {
+						font-size: 15px;
+					}
+
+					.fa-times-circle {
+						color: #f43f5e;
+					}
+
+					.fa-check-circle {
+						color: #22c55e;
+					}
+				}
+			}
+		}
+	}
+}
+
+.landing-directory-component {
+	.feed-list {
+		display: flex;
+		flex-direction: column;
+		gap: 20px;
+	}
+
+	.landing-user-card {
+		.display-name {
+			a {
+				@extend .text-bluegray-400;
+				font-size: 12px;
+				font-weight: 500;
+				text-decoration: none;
+			}
+		}
+
+		.username {
+			margin-bottom: 2px;
+
+			a {
+				color: #fff;
+				font-size: 18px;
+				font-weight: 800;
+				text-decoration: none;
+			}
+		}
+
+		.user-stats {
+			display: flex;
+			justify-content: space-between;
+
+			&-item {
+				@extend .text-bluegray-500;
+				font-size: 13px;
+				font-weight: 600;
+			}
+		}
+
+		.user-bio {
+			@extend .bg-bluegray-700;
+			margin-top: 1rem;
+			padding: 15px;
+			border-radius: 10px;
+		}
+	}
+}
+
+.landing-explore-component {
+	.feed-list {
+		display: flex;
+		flex-direction: column;
+		gap: 20px;
+
+		.landing-post-card {
+			a.text-bluegray-400 {
+				&:hover {
+					color: #cbd5e1;
+					text-decoration: none;
+				}
+			}
+
+			a.text-bluegray-500 {
+				&:hover {
+					color: #94a3b8;
+					text-decoration: none;
+				}
+			}
+
+			.read-more-component {
+				color: #64748b;
+				a {
+					color: #94a3b8;
+					font-weight: 600;
+				}
+			}
+		}
+
+	}
+}

+ 22 - 121
resources/views/site/index.blade.php

@@ -1,7 +1,6 @@
 <!DOCTYPE html>
 <html lang="{{ app()->getLocale() }}">
 <head>
-
 	<meta charset="utf-8">
 	<meta http-equiv="X-UA-Compatible" content="IE=edge">
 	<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -9,7 +8,7 @@
 
 	<meta name="mobile-web-app-capable" content="yes">
 
-	<title>{{ config('app.name', 'Laravel') }}</title>
+	<title>{{ config('app.name', 'Pixelfed') }}</title>
 
 	<meta property="og:site_name" content="{{ config('app.name', 'pixelfed') }}">
 	<meta property="og:title" content="{{ config('app.name', 'pixelfed') }}">
@@ -24,125 +23,27 @@
 	<link rel="icon" type="image/png" href="/img/favicon.png">
 	<link rel="apple-touch-icon" type="image/png" href="/img/favicon.png?v=2">
 	<link href="{{ mix('css/landing.css') }}" rel="stylesheet">
-	<style type="text/css">
-		.feature-circle {
-			display: flex !important;
-			-webkit-box-pack: center !important;
-			justify-content: center !important;
-			-webkit-box-align: center !important;
-			align-items: center !important;
-			margin-right: 1rem !important;
-			background-color: #08d !important;
-			color: #fff;
-			border-radius: 50% !important;
-			width: 60px;
-			height:60px;
-		}
-		.section-spacer {
-			height: 13vh;
-		}
-	</style>
+	<script type="text/javascript">
+		window.pfl = {!! App\Services\LandingService::get() !!}
+	</script>
 </head>
-<body class="">
-	<main id="content">
-		<section class="container">
-			<div class="section-spacer"></div>
-			<div class="row pt-md-5 mt-5">
-				<div class="col-12 col-md-6 d-none d-md-block">
-					<div class="m-my-4">
-						<p class="display-2 font-weight-bold">Photo Sharing</p>
-						<p class="h1 font-weight-bold">For Everyone.</p>
-					</div>
-
-                    <p class="lead font-weight-light mt-5">{{ config_cache('app.short_description') ?? 'Pixelfed is an image sharing platform, an ethical alternative to centralized platforms.' }}</p>
-                    <p><a href="https://pixelfed.org" target="_blank" class="font-weight-bold">Learn more</a></p>
-				</div>
-				<div class="col-12 col-md-5 offset-md-1">
-					<div>
-						<div class="pt-md-3 d-flex justify-content-center align-items-center">
-							<img src="/img/pixelfed-icon-color.svg" loading="lazy" width="50px" height="50px">
-							<span class="font-weight-bold h3 ml-2 pt-2">{{ config_cache('app.name') ?? 'Pixelfed' }}</span>
-						</div>
-						<div class="d-block d-md-none">
-							<p class="font-weight-light mt-3 mb-5 text-center px-5">{{ config_cache('app.short_description') ?? 'Pixelfed is an image sharing platform, an ethical alternative to centralized platforms.' }}</p>
-						</div>
-						<div class="card my-4 shadow-none border">
-							<div class="card-body px-lg-5">
-								<div class="text-center">
-									<p class="small text-uppercase font-weight-bold text-muted">Account Login</p>
-								</div>
-								<div>
-									<form class="px-1" method="POST" action="{{ route('login') }}" id="login_form">
-										@csrf
-										<div class="form-group row">
-
-											<div class="col-md-12">
-												<input id="email" type="email" class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}" name="email" value="{{ old('email') }}" placeholder="{{__('Email')}}" required autofocus>
-
-												@if ($errors->has('email'))
-													<span class="invalid-feedback">
-														<strong>{{ $errors->first('email') }}</strong>
-													</span>
-												@endif
-											</div>
-										</div>
-
-										<div class="form-group row">
-
-											<div class="col-md-12">
-												<input id="password" type="password" class="form-control{{ $errors->has('password') ? ' is-invalid' : '' }}" name="password" placeholder="{{__('Password')}}" required>
-
-												@if ($errors->has('password'))
-													<span class="invalid-feedback">
-														<strong>{{ $errors->first('password') }}</strong>
-													</span>
-												@endif
-											</div>
-										</div>
-
-										<div class="form-group row">
-											<div class="col-md-12">
-												<div class="checkbox">
-													<label>
-														<input type="checkbox" name="remember" {{ old('remember') ? 'checked' : '' }}>
-														<span class="font-weight-bold small ml-1 text-muted">
-															{{ __('Remember Me') }}
-														</span>
-													</label>
-												</div>
-											</div>
-										</div>
-										@if(config('captcha.enabled'))
-										<div class="d-flex justify-content-center mb-3">
-											{!! Captcha::display() !!}
-										</div>
-										@endif
-										<div class="form-group row mb-0">
-											<div class="col-md-12">
-												<button type="submit" class="btn btn-primary btn-block btn-lg font-weight-bold text-uppercase">
-													{{ __('Login') }}
-												</button>
-
-											</div>
-										</div>
-									</form>
-								</div>
-							</div>
-						</div>
-						<div class="card shadow-none border card-body">
-							<p class="text-center mb-0 font-weight-bold">
-								@if(config_cache('pixelfed.open_registration'))
-								<a href="/register">Register</a>
-								<span class="px-1">·</span>
-								@endif
-								<a href="/password/reset">Password Reset</a>
-							</p>
-						</div>
-					</div>
+	<body>
+		<main id="content">
+			<noscript>
+				<div class="container">
+					<h1 class="pt-5 text-center">Pixelfed</h1>
+					<p class="text-center">Decentralized photo sharing social media</p>
+					<p class="pt-2 text-center lead">
+						<a href="{{ config('app.url') }}/login" class="btn btn-outline-light">Login</a>
+					</p>
+					<p class="pt-2 text-center lead">Please enable javascript to view this content.</p>
 				</div>
-			</div>
-		</section>
-	</main>
-	@include('layouts.partial.footer')
-</body>
+			</noscript>
+			<navbar></navbar>
+			<router-view></router-view>
+		</main>
+		<script type="text/javascript" src="{{ mix('js/manifest.js') }}"></script>
+		<script type="text/javascript" src="{{ mix('js/vendor.js') }}"></script>
+		<script type="text/javascript" src="{{ mix('js/landing.js') }}"></script>
+	</body>
 </html>

+ 4 - 0
routes/api.php

@@ -214,4 +214,8 @@ Route::group(['prefix' => 'api'], function() use($middleware) {
 		Route::post('instances/moderate', 'Api\AdminApiController@moderateInstance')->middleware($middleware);
 		Route::post('instances/refresh-stats', 'Api\AdminApiController@refreshInstanceStats')->middleware($middleware);
 	});
+
+	Route::group(['prefix' => 'landing/v1'], function() use($middleware) {
+		Route::get('directory', 'LandingController@getDirectoryApi');
+	});
 });

+ 2 - 0
routes/web.php

@@ -150,6 +150,8 @@ Route::domain(config('portfolio.domain'))->group(function () {
 Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofactor', 'localization'])->group(function () {
 	Route::get('/', 'SiteController@home')->name('timeline.personal');
 	Route::redirect('/home', '/')->name('home');
+	Route::get('web/directory', 'LandingController@directoryRedirect');
+	Route::get('web/explore', 'LandingController@exploreRedirect');
 
 	Auth::routes();