Browse Source

Add Profile Carousels

Daniel Supernault 8 months ago
parent
commit
8af77a3f78

+ 8 - 2
app/Http/Controllers/ProfileController.php

@@ -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 - 0
package-lock.json

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

+ 1 - 0
package.json

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

+ 336 - 0
resources/assets/components/FullscreenCarousel.vue

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

+ 197 - 0
resources/assets/components/ProfileCarousel.vue

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

+ 46 - 0
resources/assets/components/SplashScreen.vue

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

+ 5 - 0
resources/assets/js/profile.js

@@ -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 - 0
resources/assets/sass/profile.scss

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

+ 42 - 0
resources/views/profile/show_carousel.blade.php

@@ -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 - 0
webpack.mix.js

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