Merge pull request #5329 from pixelfed/staging

Add Profile Carousels
This commit is contained in:
daniel 2024-10-13 23:24:54 -06:00 committed by GitHub
commit 91e2ad6415
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 652 additions and 2 deletions

View file

@ -6,6 +6,7 @@
- Implement Admin Domain Blocks API (Mastodon API Compatible) [ThisIsMissEm](https://github.com/ThisIsMissEm) ([#5021](https://github.com/pixelfed/pixelfed/pull/5021))
- Authorize Interaction support (for handling remote interactions) ([4ca7c6c3](https://github.com/pixelfed/pixelfed/commit/4ca7c6c3))
- Contact Form Admin Responses ([52cc6090](https://github.com/pixelfed/pixelfed/commit/52cc6090))
- Profile Carousels ([8af77a3f](https://github.com/pixelfed/pixelfed/commit/8af77a3f))
### Federation
- Add ActiveSharedInboxService, for efficient sharedInbox caching ([1a6a3397](https://github.com/pixelfed/pixelfed/commit/1a6a3397))

View file

@ -33,7 +33,7 @@ class ProfileController extends Controller
}
// redirect authed users to Metro 2.0
if ($request->user()) {
if ($request->user() && !$request->filled('carousel')) {
// unless they force static view
if (! $request->has('fs') || $request->input('fs') != '1') {
$pid = AccountService::usernameToId($username);
@ -64,6 +64,7 @@ class ProfileController extends Controller
protected function buildProfile(Request $request, $user)
{
$carousel = (bool) $request->filled('carousel');
$username = $user->username;
$loggedIn = Auth::check();
$isPrivate = false;
@ -97,6 +98,9 @@ class ProfileController extends Controller
],
];
if($carousel) {
return view('profile.show_carousel', compact('profile', 'settings'));
}
return view('profile.show', compact('profile', 'settings'));
} else {
$key = 'profile:settings:'.$user->id;
@ -135,7 +139,9 @@ class ProfileController extends Controller
'list' => $settings->show_profile_followers,
],
];
if($carousel) {
return view('profile.show_carousel', compact('profile', 'settings'));
}
return view('profile.show', compact('profile', 'settings'));
}
}

6
package-lock.json generated
View file

@ -7,6 +7,7 @@
"name": "pixelfed",
"dependencies": {
"@fancyapps/fancybox": "^3.5.7",
"@glidejs/glide": "^3.6.2",
"@hcaptcha/vue-hcaptcha": "^1.3.0",
"@peertube/p2p-media-loader-core": "^1.0.14",
"@peertube/p2p-media-loader-hlsjs": "^1.0.14",
@ -2140,6 +2141,11 @@
"jquery": ">=1.9.0"
}
},
"node_modules/@glidejs/glide": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@glidejs/glide/-/glide-3.6.2.tgz",
"integrity": "sha512-oXw7In0IZV69PC0PChQakY+yh+UnqIb5+zfVuEIzub6Kkfl1foo7TAhr2PZXPzihOG9YS57t8wvdzBFEZ0aPVA=="
},
"node_modules/@hcaptcha/vue-hcaptcha": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@hcaptcha/vue-hcaptcha/-/vue-hcaptcha-1.3.0.tgz",

View file

@ -34,6 +34,7 @@
},
"dependencies": {
"@fancyapps/fancybox": "^3.5.7",
"@glidejs/glide": "^3.6.2",
"@hcaptcha/vue-hcaptcha": "^1.3.0",
"@peertube/p2p-media-loader-core": "^1.0.14",
"@peertube/p2p-media-loader-hlsjs": "^1.0.14",

BIN
public/css/profile.css vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
public/js/admin.js vendored

Binary file not shown.

Binary file not shown.

BIN
public/js/app.js vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/compose.js vendored

Binary file not shown.

BIN
public/js/daci.chunk.5bb69fda8fdedc47.js vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/direct.js vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/groups.js vendored

Binary file not shown.

BIN
public/js/hashtag.js vendored

Binary file not shown.

Binary file not shown.

BIN
public/js/home.chunk.491ce6f986e08bb3.js vendored Normal file

Binary file not shown.

BIN
public/js/landing.js vendored

Binary file not shown.

BIN
public/js/manifest.js vendored

Binary file not shown.

BIN
public/js/portfolio.js vendored

Binary file not shown.

BIN
public/js/post.chunk.13919bcfbfc2d438.js vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/profile.js vendored

Binary file not shown.

View file

@ -0,0 +1 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */

Binary file not shown.

BIN
public/js/search.js vendored

Binary file not shown.

BIN
public/js/spa.js vendored

Binary file not shown.

BIN
public/js/status.js vendored

Binary file not shown.

BIN
public/js/stories.js vendored

Binary file not shown.

Binary file not shown.

BIN
public/js/timeline.js vendored

Binary file not shown.

BIN
public/js/vendor.js vendored

Binary file not shown.

View file

@ -40,6 +40,12 @@
* Date: 2024-04-21T07:43:05.335Z
*/
/*!
* Glide.js v3.6.2
* (c) 2013-2024 Jędrzej Chałubek (https://github.com/jedrzejchalubek/)
* Released under the MIT License.
*/
/*!
* JavaScript Cookie v2.2.1
* https://github.com/js-cookie/js-cookie

Binary file not shown.

View file

@ -0,0 +1,336 @@
<template>
<div class="fullscreen-carousel">
<div class="glide" ref="glide">
<div class="glide__track" data-glide-el="track">
<ul class="glide__slides">
<li class="glide__slide" v-for="(item, index) in feed" :key="index">
<div class="slide-content">
<img :src="item.media_url" :alt="item.caption" class="slide-image" loading="lazy">
<div v-if="withOverlay" class="slide-overlay">
<p v-if="withLinks" class="slide-username"><a :href="item.account.url">{{ webfinger }}</a></p>
<p v-else class="slide-username">{{ webfinger }}</p>
<div class="d-flex gap-1">
<div v-if="withLinks" class="slide-date">
<a :href="item.url" target="_blank">{{ formatDate(item.created_at) }}</a>
</div>
<div v-else class="slide-date">{{ formatDate(item.created_at) }}</div>
</div>
</div>
</div>
</li>
</ul>
</div>
<div class="glide__arrows" data-glide-el="controls">
<button class="glide__arrow glide__arrow--left fancy-arrow" data-glide-dir="<">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<button class="glide__arrow glide__arrow--right fancy-arrow" data-glide-dir=">">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
</div>
</div>
</template>
<script>
import Glide from '@glidejs/glide'
export default {
props: {
feed: {
type: Array,
required: true
},
canLoadMore: {
type: Boolean,
default: false
},
withLinks: {
type: Boolean,
default: false
},
withOverlay: {
type: Boolean,
default: true
},
autoPlay: {
type: Boolean,
default: false
},
autoPlayInterval: {
type: Number,
default: () => { return 5000; }
}
},
data() {
return {
glideInstance: null
}
},
mounted() {
this.initGlide()
},
computed: {
webfinger: {
get() {
if(this.feed && this.feed.length) {
const account = this.feed[0].account
const domain = new URL(account.url).host
return `@${account.username}@${domain}`
}
return ""
}
}
},
methods: {
initGlide() {
this.glideInstance = new Glide(this.$refs.glide, {
type: 'carousel',
startAt: 0,
perView: 1,
gap: 0,
hoverpause: false,
autoplay: this.autoPlay ? this.autoPlayInterval : false,
keyboard: true
})
this.glideInstance.on('run.after', this.checkForPagination)
this.glideInstance.mount()
},
checkForPagination() {
const currentIndex = this.glideInstance.index
if (currentIndex === this.feed.length - 1 && this.canLoadMore) {
this.$emit('load-more')
}
},
loadMore() {
this.$emit('load-more')
},
formatDate(dateInput, locale = navigator.language) {
let date;
if (typeof dateInput === 'string') {
date = new Date(dateInput);
if (isNaN(date.getTime())) {
throw new Error('Invalid date string. Please provide a valid ISO 8601 format.');
}
} else if (dateInput instanceof Date) {
date = dateInput;
} else {
throw new Error('Invalid input. Please provide a Date object or an ISO 8601 string.');
}
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
};
return new Intl.DateTimeFormat(locale, options).format(date);
},
updateGlide() {
this.$nextTick(() => {
if (this.glideInstance) {
this.glideInstance.update()
}
})
}
},
watch: {
feed() {
this.updateGlide()
}
}
}
</script>
<style scoped lang="scss">
.fullscreen-carousel {
height: 100dvh;
width: 100dvw;
position: relative;
overflow: hidden;
z-index: 2;
background: #000;
}
.glide, .glide__track, .glide__slides, .glide__slide {
height: 100%;
}
.slide-content {
position: relative;
height: 100%;
width: 100%;
}
.slide-image {
object-fit: contain;
width: 100%;
height: 100%;
}
.slide-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
color: white;
padding: 8px 20px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.gap-1 {
gap: 2rem;
}
.slide-image {
.slide-overlay {
&:not(:hover) {
height: 0;
opacity: 0;
transform: height 1s ease;
}
}
}
.slide-username {
margin: 0;
user-select: all;
font-size: 14px;
a {
color: white;
font-weight: 500;
}
}
.slide-caption {
margin: 0;
font-size: 14px;
}
.slide-date {
margin: 0;
font-size: 14px;
a {
color: white;
font-weight: bold;
text-decoration: none;
}
}
.glide__arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.5);
border: none;
font-size: 24px;
padding: 10px;
cursor: pointer;
}
.fancy-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.3s ease;
overflow: hidden;
}
.fancy-arrow:hover {
background: rgba(255, 255, 255, 0.4);
box-shadow: 0 0 15px rgba(255, 255, 255, 0.5);
}
.fancy-arrow:focus {
outline: none;
}
.fancy-arrow svg {
width: 24px;
height: 24px;
color: white;
transition: all 0.3s ease;
}
.fancy-arrow:hover svg {
transform: scale(1.2);
}
.glide__arrow--left {
left: 20px;
}
.glide__arrow--right {
right: 20px;
}
@keyframes pulse {
0% {
transform: translateY(-50%) scale(1);
}
50% {
transform: translateY(-50%) scale(1.05);
}
100% {
transform: translateY(-50%) scale(1);
}
}
.fancy-arrow:active {
animation: pulse 0.3s ease-in-out;
}
@media (max-width: 768px) {
.fancy-arrow {
width: 40px;
height: 40px;
}
.fancy-arrow svg {
width: 20px;
height: 20px;
}
.glide__arrow--left {
left: 10px;
}
.glide__arrow--right {
right: 10px;
}
}
</style>

View file

@ -0,0 +1,197 @@
<template>
<div class="profile-carousel-component">
<template v-if="showSplash">
<SplashScreen />
</template>
<template v-else>
<template v-if="emptyFeed">
<div class="bg-dark d-flex justify-content-center align-items-center w-100 h-100">
<div>
<h2 class="text-light">Oops! This account hasn't posted yet or is private.</h2>
<a href="/" class="font-weight-bold text-muted">Go back home</a>
</div>
</div>
</template>
<template v-else>
<FullscreenCarousel
:feed="feed"
:withLinks="withLinks"
:withOverlay="withOverlay"
:autoPlay="autoPlay"
:autoPlayInterval="autoPlayInterval"
:canLoadMore="hasMoreData"
@load-more="loadMoreData"
/>
</template>
</template>
</div>
</template>
<script>
import SplashScreen from './SplashScreen.vue';
import FullscreenCarousel from './FullscreenCarousel.vue'
export default {
props: ['profile-id'],
components: {
SplashScreen,
FullscreenCarousel
},
data() {
return {
showSplash: true,
profile: {},
feed: [],
emptyFeed: false,
hasMoreData: false,
withLinks: true,
withOverlay: true,
autoPlay: false,
autoPlayInterval: 5000,
maxId: null
}
},
mounted() {
const url = new URL(window.location.href);
const params = url.searchParams;
if(params.has('linkless') == true) {
this.withLinks = false;
}
if(params.has('clean') == true) {
this.withOverlay = false;
}
if(params.has('interval') == true) {
const val = parseInt(params.get('interval'));
const valid = this.validateIntegerRange(val, { min: 1000, max: 30000 })
if(valid) {
this.autoPlayInterval = val;
}
}
if(params.has('autoplay') == true) {
this.autoPlay = true;
}
this.init();
},
methods: {
async init() {
await axios.get(`/api/pixelfed/v1/accounts/${this.profileId}/statuses?media_type=photo&limit=10`)
.then(res => {
if(!res || !res.data || !res.data.length) {
this.emptyFeed = true;
return;
}
this.maxId = this.arrayMinId(res.data);
const posts = res.data.flatMap(post =>
post.media_attachments.filter(media => {
return ['image/jpeg','image/png', 'image/jpg', 'image/webp'].includes(media.mime)
}).map(media => ({
media_url: media.url,
id: post.id,
caption: post.content_text,
created_at: post.created_at,
url: post.url,
account: {
username: post.account.username,
url: post.account.url
}
}))
);
this.feed = posts;
this.hasMoreData = res.data.length === 10;
setTimeout(() => {
this.showSplash = false;
}, 3000);
})
},
async fetchMore() {
await axios.get(`/api/pixelfed/v1/accounts/${this.profileId}/statuses?media_type=photo&limit=10&max_id=${this.maxId}`)
.then(res => {
this.maxId = this.arrayMinId(res.data);
const posts = res.data.flatMap(post =>
post.media_attachments.filter(media => {
return ['image/jpeg','image/png', 'image/jpg', 'image/webp'].includes(media.mime)
}).map(media => ({
media_url: media.url,
id: post.id,
caption: post.content_text,
created_at: post.created_at,
url: post.url,
account: {
username: post.account.username,
url: post.account.url
}
}))
);
this.feed.push(...posts);
this.hasMoreData = res.data.length === 10;
})
},
arrayMinId(arr) {
if (arr.length === 0) return null;
let smallest = BigInt(arr[0].id);
for (let i = 1; i < arr.length; i++) {
const current = BigInt(arr[i].id);
if (current < smallest) {
smallest = current;
}
}
return smallest.toString();
},
loadMoreData() {
this.fetchMore();
},
validateIntegerRange(value, options = {}) {
if (typeof value !== 'number' || !Number.isInteger(value)) {
return false;
}
const {
min = Number.MIN_SAFE_INTEGER,
max = Number.MAX_SAFE_INTEGER,
inclusiveMin = true,
inclusiveMax = true
} = options;
if (min !== undefined && !Number.isInteger(min)) {
return false;
}
if (max !== undefined && !Number.isInteger(max)) {
return false;
}
if (min > max) {
return false;
}
const aboveMin = inclusiveMin ? value >= min : value > min;
const belowMax = inclusiveMax ? value <= max : value < max;
return aboveMin && belowMax;
}
}
}
</script>
<style type="text/css">
.profile-carousel-component {
display: block;
width: 100dvw;
height: 100dvh;
z-index: 2;
background: #000;
}
</style>

View file

@ -0,0 +1,46 @@
<template>
<div class="splash-screen" :class="{ 'fade-out': fadeOut }">
<img src="/img/pixelfed-icon-white.svg" alt="Pixelfed Logo" class="logo">
</div>
</template>
<script>
export default {
data() {
return {
fadeOut: false
}
},
mounted() {
setTimeout(() => {
this.fadeOut = true
}, 2000)
}
}
</script>
<style scoped>
.splash-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: black;
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
transition: opacity 1s ease-out;
}
.logo {
max-width: 200px;
max-height: 200px;
}
.fade-out {
opacity: 0;
pointer-events: none;
}
</style>

View file

@ -28,6 +28,11 @@ Vue.component(
require('./components/PostMenu.vue').default
);
Vue.component(
'profile-carousel',
require('./../components/ProfileCarousel.vue').default
);
Vue.component(
'profile',
require('./components/Profile.vue').default

2
resources/assets/sass/profile.scss vendored Normal file
View file

@ -0,0 +1,2 @@
@import "node_modules/@glidejs/glide/src/assets/sass/glide.core.scss";
@import "node_modules/@glidejs/glide/src/assets/sass/glide.theme.scss";

View file

@ -0,0 +1,42 @@
@extends('layouts.blank', [
'title' => $profile->name . ' (@' . $acct . ') - Pixelfed',
'ogTitle' => $profile->name . ' (@' . $acct . ')',
'ogType' => 'profile'
])
@php
$acct = $profile->username . '@' . config('pixelfed.domain.app');
$metaDescription = \App\Services\AccountService::getMetaDescription($profile->id);
@endphp
@section('content')
@if (session('error'))
<div class="alert alert-danger text-center font-weight-bold mb-0">
{{ session('error') }}
</div>
@endif
<profile-carousel profile-id="{{$profile->id}}" />
@endsection
@push('meta')<meta name="description" content="{{$metaDescription}}">
<meta property="og:description" content="{{$metaDescription}}">
<meta property="og:image" content="{{$profile->avatarUrl()}}">
<meta property="og:image:width" content="200">
<meta property="og:image:height" content="200">
<meta property="twitter:card" content="summary">
<meta property="profile:username" content="{{$acct}}">
<link href="{{$profile->permalink('.atom')}}" rel="alternate" title="{{$profile->username}} on Pixelfed" type="application/atom+xml">
<link href="{{$profile->permalink()}}" rel="alternate" type="application/activity+json">
<meta name="application-name" content="Pixelfed">
<meta name="generator" content="pixelfed">
<link href="{{ mix('css/profile.css') }}" rel="stylesheet">
@if($profile->website)<link href="{{$profile->website}}" rel="me" type="text/html">
@endif
@if(false == $settings['crawlable'] || $profile->remote_url)<meta name="robots" content="noindex, nofollow">@endif
@endpush
@push('scripts')<script type="text/javascript" src="{{ mix('js/profile.js') }}"></script>
<script type="text/javascript" defer>App.boot();</script>
@endpush

1
webpack.mix.js vendored
View file

@ -13,6 +13,7 @@ mix.sass('resources/assets/sass/app.scss', 'public/css')
.sass('resources/assets/sass/admin.scss', 'public/css')
.sass('resources/assets/sass/portfolio.scss', 'public/css')
.sass('resources/assets/sass/spa.scss', 'public/css')
.sass('resources/assets/sass/profile.scss', 'public/css')
.sass('resources/assets/sass/landing.scss', 'public/css').version();
mix.js('resources/assets/js/app.js', 'public/js')