New landing page design

This commit is contained in:
Daniel Supernault 2023-04-07 22:35:51 -06:00
parent e7a19b2c18
commit 09c0032b39
No known key found for this signature in database
GPG key ID: 0DEF1C662C9033F7
16 changed files with 1779 additions and 126 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
resources/assets/js/landing.js vendored Normal file
View file

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

View file

@ -2,12 +2,556 @@
@import "fonts"; @import "fonts";
@import "lib/fontawesome"; @import "lib/fontawesome";
@import "lib/inter";
@import "lib/manrope";
@import 'variables'; @import 'variables';
@import '~bootstrap/scss/bootstrap'; @import '~bootstrap/scss/bootstrap';
@import 'custom'; @import 'custom';
.container.slim { body {
width: auto; background: #080e2b;
max-width: 680px; font-family: 'Manrope', sans-serif;
padding: 0 15px; 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;
}
}
}
}
} }

View file

@ -1,7 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ app()->getLocale() }}"> <html lang="{{ app()->getLocale() }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -9,7 +8,7 @@
<meta name="mobile-web-app-capable" content="yes"> <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:site_name" content="{{ config('app.name', 'pixelfed') }}">
<meta property="og:title" 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="icon" type="image/png" href="/img/favicon.png">
<link rel="apple-touch-icon" type="image/png" href="/img/favicon.png?v=2"> <link rel="apple-touch-icon" type="image/png" href="/img/favicon.png?v=2">
<link href="{{ mix('css/landing.css') }}" rel="stylesheet"> <link href="{{ mix('css/landing.css') }}" rel="stylesheet">
<style type="text/css"> <script type="text/javascript">
.feature-circle { window.pfl = {!! App\Services\LandingService::get() !!}
display: flex !important; </script>
-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>
</head> </head>
<body class=""> <body>
<main id="content"> <main id="content">
<section class="container"> <noscript>
<div class="section-spacer"></div> <div class="container">
<div class="row pt-md-5 mt-5"> <h1 class="pt-5 text-center">Pixelfed</h1>
<div class="col-12 col-md-6 d-none d-md-block"> <p class="text-center">Decentralized photo sharing social media</p>
<div class="m-my-4"> <p class="pt-2 text-center lead">
<p class="display-2 font-weight-bold">Photo Sharing</p> <a href="{{ config('app.url') }}/login" class="btn btn-outline-light">Login</a>
<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> </p>
<p class="pt-2 text-center lead">Please enable javascript to view this content.</p>
</div> </div>
</div> </noscript>
</div> <navbar></navbar>
</div> <router-view></router-view>
</section>
</main> </main>
@include('layouts.partial.footer') <script type="text/javascript" src="{{ mix('js/manifest.js') }}"></script>
</body> <script type="text/javascript" src="{{ mix('js/vendor.js') }}"></script>
<script type="text/javascript" src="{{ mix('js/landing.js') }}"></script>
</body>
</html> </html>

View file

@ -214,4 +214,8 @@ Route::group(['prefix' => 'api'], function() use($middleware) {
Route::post('instances/moderate', 'Api\AdminApiController@moderateInstance')->middleware($middleware); Route::post('instances/moderate', 'Api\AdminApiController@moderateInstance')->middleware($middleware);
Route::post('instances/refresh-stats', 'Api\AdminApiController@refreshInstanceStats')->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');
});
}); });

View file

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