1
0
Эх сурвалжийг харах

Merge pull request #1511 from pixelfed/frontend-ui-refactor

Add Collections Feature
daniel 6 жил өмнө
parent
commit
b9697d15d6
39 өөрчлөгдсөн 917 нэмэгдсэн , 274 устгасан
  1. 27 0
      app/Collection.php
  2. 7 0
      app/CollectionItem.php
  3. 192 1
      app/Http/Controllers/CollectionController.php
  4. 5 0
      app/Profile.php
  5. 15 0
      app/Util/RateLimit/User.php
  6. 1 0
      composer.json
  7. 184 229
      composer.lock
  8. 0 2
      config/app.php
  9. 1 1
      config/instance.php
  10. 1 1
      config/pixelfed.php
  11. 3 3
      contrib/docker/Dockerfile.apache
  12. 10 9
      contrib/docker/Dockerfile.fpm
  13. BIN
      public/js/collectioncompose.js
  14. BIN
      public/js/collections.js
  15. BIN
      public/js/components.js
  16. BIN
      public/js/compose.js
  17. BIN
      public/js/developers.js
  18. BIN
      public/js/discover.js
  19. BIN
      public/js/hashtag.js
  20. BIN
      public/js/loops.js
  21. BIN
      public/js/mode-dot.js
  22. BIN
      public/js/profile.js
  23. BIN
      public/js/quill.js
  24. BIN
      public/js/search.js
  25. BIN
      public/js/status.js
  26. BIN
      public/js/theme-monokai.js
  27. BIN
      public/js/timeline.js
  28. BIN
      public/js/vendor.js
  29. BIN
      public/mix-manifest.json
  30. 4 0
      resources/assets/js/collectioncompose.js
  31. 4 0
      resources/assets/js/collections.js
  32. 69 0
      resources/assets/js/components/CollectionComponent.vue
  33. 257 0
      resources/assets/js/components/CollectionCompose.vue
  34. 6 2
      resources/assets/js/components/ComposeModal.vue
  35. 61 26
      resources/assets/js/components/Profile.vue
  36. 24 0
      resources/views/collection/create.blade.php
  37. 33 0
      resources/views/collection/show.blade.php
  38. 10 0
      routes/web.php
  39. 3 0
      webpack.mix.js

+ 27 - 0
app/Collection.php

@@ -2,6 +2,7 @@
 
 namespace App;
 
+use Illuminate\Support\Str;
 use Illuminate\Database\Eloquent\Model;
 use Pixelfed\Snowflake\HasSnowflakePrimary;
 
@@ -16,8 +17,34 @@ class Collection extends Model
      */
     public $incrementing = false;
 
+    public $fillable = ['profile_id', 'published_at'];
+
+    public $dates = ['published_at'];
+
 	public function profile()
 	{
 		return $this->belongsTo(Profile::class);
 	}
+
+    public function items()
+    {
+        return $this->hasMany(CollectionItem::class);
+    }
+
+    public function posts()
+    {
+        return $this->hasManyThrough(
+            Status::class,
+            CollectionItem::class,
+            'collection_id',
+            'id',
+            'id',
+            'object_id',
+        );
+    }
+
+    public function url()
+    {
+        return url("/c/{$this->id}");
+    }
 }

+ 7 - 0
app/CollectionItem.php

@@ -9,6 +9,13 @@ class CollectionItem extends Model
 {
 	use HasSnowflakePrimary;
 
+    public $fillable = [
+        'collection_id',
+        'object_type',
+        'object_id',
+        'order'
+    ];
+    
     /**
      * Indicates if the IDs are auto-incrementing.
      *

+ 192 - 1
app/Http/Controllers/CollectionController.php

@@ -3,8 +3,199 @@
 namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
+use Auth;
+use App\{
+    Collection,
+    CollectionItem,
+    Profile,
+    Status
+};
+use League\Fractal;
+use App\Transformer\Api\{
+    AccountTransformer,
+    StatusTransformer,
+};
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Pagination\IlluminatePaginatorAdapter;
 
 class CollectionController extends Controller
 {
-    //
+    public function create(Request $request)
+    {
+        abort_if(!Auth::check(), 403);
+        $profile = Auth::user()->profile;
+
+        $collection = Collection::firstOrCreate([
+            'profile_id' => $profile->id,
+            'published_at' => null
+        ]);
+        return view('collection.create', compact('collection'));
+    }
+
+    public function show(Request $request, int $collection)
+    {
+        $collection = Collection::whereNotNull('published_at')->findOrFail($collection);
+        if($collection->profile->status != null) {
+            abort(404);
+        }
+        if($collection->visibility !== 'public') {
+            abort_if(!Auth::check() || Auth::user()->profile_id != $collection->profile_id, 404);
+        }
+    	return view('collection.show', compact('collection'));
+    }
+
+    public function index(Request $request)
+    {
+        abort_if(!Auth::check(), 403);
+    	return $request->all();
+    }
+
+    public function store(Request $request, int $id)
+    {
+        abort_if(!Auth::check(), 403);
+        $this->validate($request, [
+            'title'         => 'nullable',
+            'description'   => 'nullable',
+            'visibility'    => 'required|alpha|in:public,private'
+        ]);
+
+        $profile = Auth::user()->profile;   
+        $collection = Collection::whereProfileId($profile->id)->findOrFail($id);
+        $collection->title = e($request->input('title'));
+        $collection->description = e($request->input('description'));
+        $collection->visibility = e($request->input('visibility'));
+        $collection->save();
+
+        return 200;
+    }
+
+    public function publish(Request $request, int $id)
+    {
+        abort_if(!Auth::check(), 403);
+        $this->validate($request, [
+            'title'         => 'nullable',
+            'description'   => 'nullable',
+            'visibility'    => 'required|alpha|in:public,private'
+        ]);
+        $profile = Auth::user()->profile;   
+        $collection = Collection::whereProfileId($profile->id)->findOrFail($id);
+        $collection->title = e($request->input('title'));
+        $collection->description = e($request->input('description'));
+        $collection->visibility = e($request->input('visibility'));
+        $collection->published_at = now();
+        $collection->save();
+
+        return $collection->url();
+    }
+
+    public function delete(Request $request, int $id)
+    {
+        abort_if(!Auth::check(), 403);
+        $user = Auth::user();
+
+        $collection = Collection::whereProfileId($user->profile_id)->findOrFail($id);
+        $collection->items()->delete();
+        $collection->delete();
+
+        return 200;
+    }
+
+    public function storeId(Request $request)
+    {
+        $this->validate($request, [
+            'collection_id' => 'required|int|min:1|exists:collections,id',
+            'post_id'       => 'required|int|min:1|exists:statuses,id'
+        ]);
+        
+        $profileId = Auth::user()->profile_id;
+        $collectionId = $request->input('collection_id');
+        $postId = $request->input('post_id');
+
+        $collection = Collection::whereProfileId($profileId)->findOrFail($collectionId);
+        $count = $collection->items()->count();
+
+        if($count >= 18) {
+            abort(400, 'You can only add 18 posts per collection');
+        }
+
+        $status = Status::whereScope('public')
+            ->whereIn('type', ['photo'])
+            ->findOrFail($postId);
+
+        $item = CollectionItem::firstOrCreate([
+            'collection_id' => $collection->id,
+            'object_type'   => 'App\Status',
+            'object_id'     => $status->id
+        ],[
+            'order'         => $count,
+        ]);
+
+        return 200;
+    }
+
+    public function get(Request $request, int $id)
+    {
+        $profile = Auth::check() ? Auth::user()->profile : [];
+
+        $collection = Collection::whereVisibility('public')->findOrFail($id);
+        if($collection->published_at == null) {
+            if(!Auth::check() || $profile->id !== $collection->profile_id) {
+                abort(404);
+            }
+        }
+
+        return [
+            'id'            => $collection->id,
+            'title'         => $collection->title,
+            'description'   => $collection->description,
+            'visibility'    => $collection->visibility
+        ];
+    }
+
+    public function getItems(Request $request, int $id)
+    {
+        $collection = Collection::findOrFail($id);
+        if($collection->visibility !== 'public') {
+            abort_if(!Auth::check() || Auth::user()->profile_id != $collection->profile_id, 404);
+        }
+        $posts = $collection->posts()->orderBy('order', 'asc')->paginate(18);
+
+        $fractal = new Fractal\Manager();
+        $fractal->setSerializer(new ArraySerializer());
+        $resource = new Fractal\Resource\Collection($posts, new StatusTransformer());
+        $res = $fractal->createData($resource)->toArray();
+
+        return response()->json($res);
+    }
+
+    public function getUserCollections(Request $request, int $id)
+    {
+        $profile = Profile::whereNull('status')
+            ->whereNull('domain')
+            ->findOrFail($id);
+
+        if($profile->is_private) {
+            abort_if(!Auth::check(), 404);
+            abort_if(!$profile->followedBy(Auth::user()->profile) && $profile->id != Auth::user()->profile_id, 404);
+        }
+
+        return $profile
+            ->collections()
+            ->has('posts')
+            ->with('posts')
+            ->whereVisibility('public')
+            ->whereNotNull('published_at')
+            ->orderByDesc('published_at')
+            ->paginate(9)
+            ->map(function($collection) {
+                return [
+                    'id' => $collection->id,
+                    'title' => $collection->title,
+                    'description' => $collection->description,
+                    'thumb' => $collection->posts()->first()->thumb(),
+                    'url' => $collection->url(),
+                    'published_at' => $collection->published_at
+                ];
+        });
+    }
 }

+ 5 - 0
app/Profile.php

@@ -291,4 +291,9 @@ class Profile extends Model
     {
         return $this->hasMany(HashtagFollow::class);
     }
+
+    public function collections()
+    {
+        return $this->hasMany(Collection::class);
+    }
 }

+ 15 - 0
app/Util/RateLimit/User.php

@@ -68,4 +68,19 @@ trait User {
 	{
 		return 100;
 	}
+
+	public function getMaxCollectionsPerHourAttribute()
+	{
+		return 10;
+	}
+
+	public function getMaxCollectionsPerDayAttribute()
+	{
+		return 20;
+	}
+
+	public function getMaxCollectionsPerMonthAttribute()
+	{
+		return 100;
+	}
 }

+ 1 - 0
composer.json

@@ -42,6 +42,7 @@
         "fzaninotto/faker": "^1.4",
         "mockery/mockery": "^1.0",
         "nunomaduro/collision": "^2.0",
+        "nunomaduro/phpinsights": "^1.7",
         "phpunit/phpunit": "^7.5"
     },
     "autoload": {

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 184 - 229
composer.lock


+ 0 - 2
config/app.php

@@ -150,7 +150,6 @@ return [
         /*
          * Package Service Providers...
          */
-        Jackiedo\DotenvEditor\DotenvEditorServiceProvider::class,
 
         /*
          * Application Service Providers...
@@ -211,7 +210,6 @@ return [
         'Validator'    => Illuminate\Support\Facades\Validator::class,
         'View'         => Illuminate\Support\Facades\View::class,
 
-        'DotenvEditor' => Jackiedo\DotenvEditor\Facades\DotenvEditor::class,
         'PrettyNumber' => App\Util\Lexer\PrettyNumber::class,
         'Purify'       => Stevebauman\Purify\Facades\Purify::class,
         'FFMpeg'       => Pbmedia\LaravelFFMpeg\FFMpegFacade::class,

+ 1 - 1
config/instance.php

@@ -3,7 +3,7 @@
 return [
 
 	'announcement' => [
-		'enabled' => env('INSTANCE_ANNOUNCEMENT_ENABLED', true),
+		'enabled' => env('INSTANCE_ANNOUNCEMENT_ENABLED', false),
 		'message' => env('INSTANCE_ANNOUNCEMENT_MESSAGE', 'Example announcement message.<br><span class="font-weight-normal">Something else here</span>')
 	],
 

+ 1 - 1
config/pixelfed.php

@@ -23,7 +23,7 @@ return [
     | This value is the version of your Pixelfed instance.
     |
     */
-    'version' => '0.9.6',
+    'version' => '0.10.0',
 
     /*
     |--------------------------------------------------------------------------

+ 3 - 3
contrib/docker/Dockerfile.apache

@@ -1,4 +1,4 @@
-FROM php:7.3-apache
+FROM php:7.3-apache-buster
 
 ARG COMPOSER_VERSION="1.8.5"
 ARG COMPOSER_CHECKSUM="4e4c1cd74b54a26618699f3190e6f5fc63bb308b13fa660f71f2a2df047c0e17"
@@ -7,13 +7,13 @@ RUN apt-get update \
  && apt-get install -y --no-install-recommends apt-utils \
  && apt-get install -y --no-install-recommends git gosu \
       optipng pngquant jpegoptim gifsicle libpq-dev libsqlite3-dev locales zip unzip libzip-dev libcurl4-openssl-dev \
-      libfreetype6 libicu-dev libjpeg62-turbo libpng16-16 libxpm4 libwebp6 libmagickwand-6.q16-3 \
+      libfreetype6 libicu-dev libjpeg62-turbo libpng16-16 libxpm4 libwebp6 libmagickwand-6.q16-6 \
       libfreetype6-dev libjpeg62-turbo-dev libpng-dev libxpm-dev libwebp-dev libmagickwand-dev \
  && sed -i '/en_US/s/^#//g' /etc/locale.gen \
  && locale-gen && update-locale \
  && docker-php-source extract \
  && docker-php-ext-configure gd \
-      --with-freetype-dir=/usr/lib/x86_64-linux-gnu/ \
+      --enable-freetype \
       --with-jpeg-dir=/usr/lib/x86_64-linux-gnu/ \
       --with-xpm-dir=/usr/lib/x86_64-linux-gnu/ \
       --with-webp-dir=/usr/lib/x86_64-linux-gnu/ \

+ 10 - 9
contrib/docker/Dockerfile.fpm

@@ -1,24 +1,25 @@
-FROM php:7.2-fpm
+FROM php:7.3-fpm-buster
 
 ARG COMPOSER_VERSION="1.8.5"
 ARG COMPOSER_CHECKSUM="4e4c1cd74b54a26618699f3190e6f5fc63bb308b13fa660f71f2a2df047c0e17"
 
 RUN apt-get update \
+ && apt-get install -y --no-install-recommends apt-utils \
  && apt-get install -y --no-install-recommends git gosu \
-      optipng pngquant jpegoptim gifsicle libpq-dev libsqlite3-dev locales zip unzip libzip-dev \
-      libfreetype6 libjpeg62-turbo libpng16-16 libxpm4 libvpx4 libmagickwand-6.q16-3 \
-      libfreetype6-dev libjpeg62-turbo-dev libpng-dev libxpm-dev libvpx-dev libmagickwand-dev \
+      optipng pngquant jpegoptim gifsicle libpq-dev libsqlite3-dev locales zip unzip libzip-dev libcurl4-openssl-dev \
+      libfreetype6 libicu-dev libjpeg62-turbo libpng16-16 libxpm4 libwebp6 libmagickwand-6.q16-6 \
+      libfreetype6-dev libjpeg62-turbo-dev libpng-dev libxpm-dev libwebp-dev libmagickwand-dev \
  && sed -i '/en_US/s/^#//g' /etc/locale.gen \
  && locale-gen && update-locale \
  && docker-php-source extract \
  && docker-php-ext-configure gd \
-      --with-freetype-dir=/usr/lib/x86_64-linux-gnu/ \
+      --enable-freetype \
       --with-jpeg-dir=/usr/lib/x86_64-linux-gnu/ \
       --with-xpm-dir=/usr/lib/x86_64-linux-gnu/ \
-      --with-vpx-dir=/usr/lib/x86_64-linux-gnu/ \
- && docker-php-ext-install pdo_mysql pdo_pgsql pdo_sqlite pcntl gd exif bcmath intl zip \
+      --with-webp-dir=/usr/lib/x86_64-linux-gnu/ \
+ && docker-php-ext-install pdo_mysql pdo_pgsql pdo_sqlite pcntl gd exif bcmath intl zip curl \
  && pecl install imagick \
- && docker-php-ext-enable imagick pcntl imagick gd exif zip \
+ && docker-php-ext-enable imagick pcntl imagick gd exif zip curl \
  && curl -LsS https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar -o /usr/bin/composer \
  && echo "${COMPOSER_CHECKSUM}  /usr/bin/composer" | sha256sum -c - \
  && chmod 755 /usr/bin/composer \
@@ -32,7 +33,7 @@ ENV PATH="~/.composer/vendor/bin:./vendor/bin:${PATH}"
 COPY . /var/www/
 
 WORKDIR /var/www/
-RUN cp -r storage storage.skel \
+RUN mkdir public.ext && cp -r storage storage.skel \
  && cp contrib/docker/php.ini /usr/local/etc/php/conf.d/pixelfed.ini \
  && composer install --prefer-dist --no-interaction \
  && rm -rf html && ln -s public html

BIN
public/js/collectioncompose.js


BIN
public/js/collections.js


BIN
public/js/components.js


BIN
public/js/compose.js


BIN
public/js/developers.js


BIN
public/js/discover.js


BIN
public/js/hashtag.js


BIN
public/js/loops.js


BIN
public/js/mode-dot.js


BIN
public/js/profile.js


BIN
public/js/quill.js


BIN
public/js/search.js


BIN
public/js/status.js


BIN
public/js/theme-monokai.js


BIN
public/js/timeline.js


BIN
public/js/vendor.js


BIN
public/mix-manifest.json


+ 4 - 0
resources/assets/js/collectioncompose.js

@@ -0,0 +1,4 @@
+Vue.component(
+    'collection-compose',
+    require('./components/CollectionCompose.vue').default
+);

+ 4 - 0
resources/assets/js/collections.js

@@ -0,0 +1,4 @@
+Vue.component(
+    'collection-component',
+    require('./components/CollectionComponent.vue').default
+);

+ 69 - 0
resources/assets/js/components/CollectionComponent.vue

@@ -0,0 +1,69 @@
+<template>
+<div>
+	<div class="row">
+		<div class="col-4 p-0 p-sm-2 p-md-3 p-xs-1" v-for="(s, index) in posts">
+			<a class="card info-overlay card-md-border-0" :href="s.url">
+				<div class="square">
+					<span v-if="s.pf_type == 'photo:album'" class="float-right mr-3 post-icon"><i class="fas fa-images fa-2x"></i></span>
+					<span v-if="s.pf_type == 'video'" class="float-right mr-3 post-icon"><i class="fas fa-video fa-2x"></i></span>
+					<span v-if="s.pf_type == 'video:album'" class="float-right mr-3 post-icon"><i class="fas fa-film fa-2x"></i></span>
+					<div class="square-content" v-bind:style="previewBackground(s)">
+					</div>
+					<div class="info-overlay-text">
+						<h5 class="text-white m-auto font-weight-bold">
+							<span>
+								<span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
+								<span class="d-flex-inline">{{s.favourites_count}}</span>
+							</span>
+							<span>
+								<span class="fas fa-retweet fa-lg p-2 d-flex-inline"></span>
+								<span class="d-flex-inline">{{s.reblogs_count}}</span>
+							</span>
+						</h5>
+					</div>
+				</div>
+			</a>
+		</div>
+	</div>
+</div>
+</template>
+
+<style type="text/css" scoped></style>
+
+<script type="text/javascript">
+export default {
+	props: ['collection-id'],
+
+	data() {
+		return {
+			loaded: false,
+			posts: [],
+		}
+	},
+
+	beforeMount() {
+		this.fetchItems();
+	},
+
+	mounted() {
+	},
+
+	methods: {
+		fetchItems() {
+			axios.get('/api/local/collection/items/' + this.collectionId)
+			.then(res => {
+				this.posts = res.data;
+			});
+		},
+		
+		previewUrl(status) {
+			return status.sensitive ? '/storage/no-preview.png?v=' + new Date().getTime() : status.media_attachments[0].preview_url;
+		},
+
+		previewBackground(status) {
+			let preview = this.previewUrl(status);
+			return 'background-image: url(' + preview + ');';
+		},
+	}
+}
+</script>

+ 257 - 0
resources/assets/js/components/CollectionCompose.vue

@@ -0,0 +1,257 @@
+<template>
+<div class="container">
+	<div v-if="loaded" class="row">
+		<div class="col-12 col-md-6 offset-md-3 pt-5">
+			<div class="text-center pb-4">
+				<h1>Create Collection</h1>
+			</div>
+		</div>
+		<div class="col-12 col-md-4 pt-3">
+			<div class="card rounded-0 shadow-none border " style="min-height: 440px;">
+				<div class="card-body">
+					<div>
+						<form>
+							<div class="form-group">
+								<label for="title" class="font-weight-bold text-muted">Title</label>
+								<input type="text" class="form-control" id="title" placeholder="Collection Title" v-model="collection.title">
+							</div>
+							<div class="form-group">
+								<label for="description" class="font-weight-bold text-muted">Description</label>
+								<textarea class="form-control" id="description" placeholder="Example description here" v-model="collection.description" rows="3">
+								</textarea>
+							</div>
+							<div class="form-group">
+								<label for="visibility" class="font-weight-bold text-muted">Visibility</label>
+								<select class="custom-select" v-model="collection.visibility">
+									<option value="public">Public</option>
+									<option value="private">Followers Only</option>
+								</select>
+							</div>
+						</form>
+						<hr>
+						<p>
+							<button type="button" class="btn btn-primary font-weight-bold btn-block" @click="publish">Publish</button>
+						</p>
+						<p>
+							<button type="button" class="btn btn-outline-primary font-weight-bold btn-block" @click="save">Save</button>
+						</p>
+						<p class="mb-0">
+							<button type="button" class="btn btn-outline-secondary font-weight-bold btn-block" @click="deleteCollection">Delete</button>
+						</p>
+					</div>
+				</div>
+			</div>
+		</div>
+		<div class="col-12 col-md-8 pt-3">
+			<div>
+				<ul class="nav nav-tabs">
+					<li class="nav-item">
+						<a :class="[tab == 'add' ? 'nav-link font-weight-bold bg-white active' : 'nav-link font-weight-bold text-muted']" href="#" @click.prevent="tab = 'add'">Add Posts</a>
+					</li>
+					<li class="nav-item">
+						<a :class="[tab == 'all' ? 'nav-link font-weight-bold bg-white active' : 'nav-link font-weight-bold text-muted']" href="#" @click.prevent="tab = 'all'">Preview</a>
+					</li>
+				</ul>
+			</div>
+			<div class="card rounded-0 shadow-none border border-top-0">
+				<div class="card-body" style="height: 460px; overflow-y: auto">
+					<div v-if="tab == 'all'" class="row">
+						<div class="col-4 p-1" v-for="(s, index) in posts">
+							<a class="card info-overlay card-md-border-0" :href="s.url">
+								<div class="square">
+									<span v-if="s.pf_type == 'photo:album'" class="float-right mr-3 post-icon"><i class="fas fa-images fa-2x"></i></span>
+									<span v-if="s.pf_type == 'video'" class="float-right mr-3 post-icon"><i class="fas fa-video fa-2x"></i></span>
+									<span v-if="s.pf_type == 'video:album'" class="float-right mr-3 post-icon"><i class="fas fa-film fa-2x"></i></span>
+									<div class="square-content" v-bind:style="previewBackground(s)">
+									</div>
+									<div class="info-overlay-text">
+										<h5 class="text-white m-auto font-weight-bold">
+											<span>
+												<span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
+												<span class="d-flex-inline">{{s.favourites_count}}</span>
+											</span>
+											<span>
+												<span class="fas fa-retweet fa-lg p-2 d-flex-inline"></span>
+												<span class="d-flex-inline">{{s.reblogs_count}}</span>
+											</span>
+										</h5>
+									</div>
+								</div>
+							</a>
+						</div>
+					</div>
+					<div v-if="tab == 'add'">
+						<div class="form-group">
+							<label for="title" class="font-weight-bold text-muted">Add Post by URL</label>
+							<input type="text" class="form-control" placeholder="https://pixelfed.dev/p/admin/1" v-model="id">
+							<p class="help-text small text-muted">Only local, public posts can be added</p>
+						</div>
+						<div class="form-group pt-4">
+							<label for="title" class="font-weight-bold text-muted">Add Recent Post</label>
+							<div>
+								<div v-for="(s, index) in recentPosts" :class="[selectedPost == s.id ? 'box-shadow border border-warning d-inline-block m-1':'d-inline-block m-1']" @click="selectPost(s)">
+									<div class="cursor-pointer" :style="'width: 175px; height: 175px; ' + previewBackground(s)"></div>
+								</div>
+							</div>
+						</div>
+						<hr>
+						<button type="button" class="btn btn-primary font-weight-bold btn-block" @click="addId">Add Post</button>
+					</div>
+					<div v-if="tab == 'order'">
+						
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script type="text/javascript">
+export default {
+	props: ['collection-id', 'profile-id'],
+	data() {
+		return {
+			loaded: false,
+			limit: 8,
+			step: 1,
+			title: '',
+			description: '',
+			visibility: 'private',
+			collection: {
+				title: '',
+				description: '',
+				visibility: 'public'
+			},
+			id: '',
+			posts: [],
+			tab: 'add',
+			tabs: [
+				'all',
+				'add',
+				'order'
+			],
+			recentPosts: [],
+			selectedPost: '',
+		}
+	},
+	beforeMount() {
+		axios.get('/api/local/collection/' + this.collectionId)
+		.then(res => {
+			this.collection = res.data;
+		});
+	},
+	mounted() {
+		this.fetchRecentPosts();
+		this.fetchItems();
+	},
+	methods: {
+		addToIds(id) {
+			axios.post('/api/local/collection/item', {
+				collection_id: this.collectionId,
+				post_id: id
+			}).then(res => {
+				this.fetchItems();
+				this.fetchRecentPosts();
+				this.tab = 'all';
+				this.id = '';
+			}).catch(err => {
+				swal('Invalid URL', 'The post you entered was invalid', 'error');
+				this.id = '';
+			})
+		},
+
+		fetchItems() {
+			axios.get('/api/local/collection/items/' + this.collectionId)
+			.then(res => {
+				this.posts = res.data;
+				this.loaded = true;
+			});
+		},
+
+		addId() {
+			let max = 18;
+			if(this.posts.length >= max) {
+				swal('Error', 'You can only add ' + max + ' posts per collection', 'error');
+				return;
+			}
+			let url = this.id;
+			let origin = window.location.origin;
+			let split = url.split('/');
+			if(url.slice(0, origin.length) !== origin) {
+				swal('Invalid URL', 'You can only add posts from this instance', 'error');
+				this.id = '';
+			}
+			if(url.slice(0, origin.length + 3) !== origin + '/p/' || split.length !== 6) {
+				swal('Invalid URL', 'Invalid URL', 'error');
+				this.id = '';
+			}
+			this.addToIds(split[5]);
+			return;
+		},
+
+		previewUrl(status) {
+			return status.sensitive ? '/storage/no-preview.png?v=' + new Date().getTime() : status.media_attachments[0].preview_url;
+		},
+
+		previewBackground(status) {
+			let preview = this.previewUrl(status);
+			return 'background-image: url(' + preview + ');background-size:cover;';
+		},
+
+		fetchRecentPosts() {
+			axios.get('/api/v1/accounts/' + this.profileId + '/statuses', {
+				params: {
+					only_media: true,
+					min_id: 1,
+				}
+			}).then(res => {
+				this.recentPosts = res.data.filter(s => {
+					let ids = this.posts.map(s => {
+						return s.id;
+					});
+					return s.visibility == 'public' && s.sensitive == false && ids.indexOf(s.id) == -1;
+				}).slice(0,3);
+			});
+		},
+
+		selectPost(status) {
+			this.selectedPost = status.id;
+			this.id = status.url;
+		},
+
+		publish() {
+			axios.post('/api/local/collection/' + this.collectionId + '/publish', {
+				title: this.collection.title,
+				description: this.collection.description,
+				visibility: this.collection.visibility	
+			})
+			.then(res => {
+				window.location.href = res.data;
+			});
+		},
+
+		save() {
+			axios.post('/api/local/collection/' + this.collectionId, {
+				title: this.collection.title,
+				description: this.collection.description,
+				visibility: this.collection.visibility
+			})
+			.then(res => {
+				swal('Saved!', 'You have successfully saved this collection.', 'success');
+			});
+		},
+
+		deleteCollection() {
+			let confirm = window.confirm('Are you sure you want to delete this collection?');
+			if(!confirm) {
+				return;
+			}
+			axios.delete('/api/local/collection/' + this.collectionId)
+			.then(res => {
+				window.location.href = '/';
+			});
+		}
+	}
+}
+</script>

+ 6 - 2
resources/assets/js/components/ComposeModal.vue

@@ -14,9 +14,9 @@
 							<span class="fas fa-ellipsis-v fa-lg text-muted"></span>
 						</button>
 						<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
-							<div v-show="media.length > 0" class="dropdown-item small font-weight-bold" v-on:click="mediaDrawer = !mediaDrawer">{{mediaDrawer ? 'Hide' : 'Show'}} Media Toolbar</div>
-							<div class="dropdown-item small font-weight-bold" v-on:click="about">About</div>
+							<div class="dropdown-item small font-weight-bold" v-on:click="createCollection">Create Collection</div>
 							<div class="dropdown-divider"></div>
+							<div class="dropdown-item small font-weight-bold" v-on:click="about">About</div>
 							<div class="dropdown-item small font-weight-bold" v-on:click="closeModal">Close</div>
 						</div>
 					</div>
@@ -507,6 +507,10 @@ export default {
 			return video ? 
 			'Click here to add photos or videos' :
 			'Click here to add photos';
+		},
+
+		createCollection() {
+			window.location.href = '/i/collections/create';
 		}
 	}
 }

+ 61 - 26
resources/assets/js/components/Profile.vue

@@ -152,9 +152,9 @@
 					<li class="nav-item px-3">
 						<a :class="this.mode == 'list' ? 'nav-link font-weight-bold text-uppercase text-primary' : 'nav-link font-weight-bold text-uppercase'" href="#" v-on:click.prevent="switchMode('list')"><i class="fas fa-th-list fa-lg"></i></a>
 					</li>
-					<!-- <li class="nav-item pr-3">
+					<li class="nav-item pr-3">
 						<a :class="this.mode == 'collections' ? 'nav-link font-weight-bold text-uppercase text-primary' : 'nav-link font-weight-bold text-uppercase'" href="#" v-on:click.prevent="switchMode('collections')"><i class="fas fa-images fa-lg"></i></a>
-					</li> -->
+					</li>
 					<li class="nav-item" v-if="owner">
 						<a :class="this.mode == 'bookmarks' ? 'nav-link font-weight-bold text-uppercase text-primary' : 'nav-link font-weight-bold text-uppercase'" href="#" v-on:click.prevent="switchMode('bookmarks')"><i class="fas fa-bookmark fa-lg"></i></a>
 					</li>
@@ -189,7 +189,7 @@
 						</div>
 					</div>
 					<div class="row" v-if="mode == 'list'">
-						<div class="col-md-8 col-lg-8 offset-md-2 px-0 mb-3 timeline">
+						<div class="col-md-8 col-lg-8 offset-md-2 px-0 timeline">
 							<div class="card status-card card-md-rounded-0 my-sm-2 my-md-3 my-lg-4" :data-status-id="status.id" v-for="(status, index) in timeline" :key="status.id">
 
 								<div class="card-header d-inline-flex align-items-center bg-white">
@@ -282,6 +282,12 @@
 							</div>
 						</div>
 					</div>
+					<div v-if="['grid','list'].indexOf(mode) != -1 && timeline.length == 0">
+						<div class="py-5 text-center text-muted">
+							<p><i class="fas fa-camera-retro fa-2x"></i></p>
+							<p class="h2 font-weight-light pt-3">No posts yet</p>
+						</div>
+					</div>
 					<div v-if="timeline.length && ['grid','list'].indexOf(mode) != -1">
 						<infinite-loading @infinite="infiniteTimeline">
 							<div slot="no-more"></div>
@@ -289,32 +295,55 @@
 						</infinite-loading>
 					</div>
 					<div class="row" v-if="mode == 'bookmarks'">
-						<div class="col-4 p-0 p-sm-2 p-md-3 p-xs-1" v-for="(s, index) in bookmarks">
-							<a class="card info-overlay card-md-border-0" :href="s.url">
-								<div class="square">
-									<span v-if="s.pf_type == 'photo:album'" class="float-right mr-3 post-icon"><i class="fas fa-images fa-2x"></i></span>
-									<span v-if="s.pf_type == 'video'" class="float-right mr-3 post-icon"><i class="fas fa-video fa-2x"></i></span>
-									<span v-if="s.pf_type == 'video:album'" class="float-right mr-3 post-icon"><i class="fas fa-film fa-2x"></i></span>
-									<div class="square-content" v-bind:style="previewBackground(s)">
-									</div>
-									<div class="info-overlay-text">
-										<h5 class="text-white m-auto font-weight-bold">
-											<span>
-												<span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
-												<span class="d-flex-inline">{{s.favourites_count}}</span>
-											</span>
-											<span>
-												<span class="fas fa-retweet fa-lg p-2 d-flex-inline"></span>
-												<span class="d-flex-inline">{{s.reblogs_count}}</span>
-											</span>
-										</h5>
+						<div v-if="bookmarks.length">
+							<div class="col-4 p-0 p-sm-2 p-md-3 p-xs-1" v-for="(s, index) in bookmarks">
+								<a class="card info-overlay card-md-border-0" :href="s.url">
+									<div class="square">
+										<span v-if="s.pf_type == 'photo:album'" class="float-right mr-3 post-icon"><i class="fas fa-images fa-2x"></i></span>
+										<span v-if="s.pf_type == 'video'" class="float-right mr-3 post-icon"><i class="fas fa-video fa-2x"></i></span>
+										<span v-if="s.pf_type == 'video:album'" class="float-right mr-3 post-icon"><i class="fas fa-film fa-2x"></i></span>
+										<div class="square-content" v-bind:style="previewBackground(s)">
+										</div>
+										<div class="info-overlay-text">
+											<h5 class="text-white m-auto font-weight-bold">
+												<span>
+													<span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
+													<span class="d-flex-inline">{{s.favourites_count}}</span>
+												</span>
+												<span>
+													<span class="fas fa-retweet fa-lg p-2 d-flex-inline"></span>
+													<span class="d-flex-inline">{{s.reblogs_count}}</span>
+												</span>
+											</h5>
+										</div>
 									</div>
-								</div>
-							</a>
+								</a>
+							</div>
+						</div>
+						<div v-else class="col-12">
+							<div class="py-5 text-center text-muted">
+								<p><i class="fas fa-bookmark fa-2x"></i></p>
+								<p class="h2 font-weight-light pt-3">You have no saved bookmarks</p>
+							</div>
 						</div>
 					</div>
-					<div class="row" v-if="mode == 'collections'">
-						<p class="text-center">Collections here</p>
+					<div class="col-12" v-if="mode == 'collections'">
+						<div v-if="collections.length" class="row">
+							<div class="col-4 p-0 p-sm-2 p-md-3 p-xs-1" v-for="(c, index) in collections">
+								<a class="card info-overlay card-md-border-0" :href="c.url">
+									<div class="square">
+										<div class="square-content" v-bind:style="'background-image: url(' + c.thumb + ');'">
+										</div>
+									</div>
+								</a>
+							</div>
+						</div>
+						<div v-else>
+							<div class="py-5 text-center text-muted">
+								<p><i class="fas fa-images fa-2x"></i></p>
+								<p class="h2 font-weight-light pt-3">No collections yet</p>
+							</div>
+						</div>
 					</div>
 				</div>
 			</div>
@@ -688,6 +717,12 @@
 						this.bookmarks = res.data
 					});
 				}
+				if(this.mode == 'collections' && this.collections.length == 0) {
+					axios.get('/api/local/profile/collections/' + this.profileId)
+					.then(res => {
+						this.collections = res.data
+					});
+				}
 			},
 
 			reportProfile() {

+ 24 - 0
resources/views/collection/create.blade.php

@@ -0,0 +1,24 @@
+@extends('layouts.app')
+
+@section('content')
+
+<collection-compose collection-id="{{$collection->id}}" profile-id="{{Auth::user()->profile_id}}"></collection-compose>
+
+@endsection
+
+@push('styles')
+<style type="text/css">
+</style>
+@endpush
+
+@push('scripts')
+<script type="text/javascript" src="{{ mix('js/collectioncompose.js') }}"></script>
+<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
+<script type="text/javascript">
+$(document).ready(function() {
+  new Vue({ 
+    el: '#content'
+  });
+});
+</script>
+@endpush

+ 33 - 0
resources/views/collection/show.blade.php

@@ -0,0 +1,33 @@
+@extends('layouts.app')
+
+@section('content')
+
+<div class="container">
+	<div class="row">
+		<div class="col-12 mt-5 py-5">
+			<div class="text-center">
+				<h1>Collection</h1>
+				<h4 class="text-muted">{{$collection->title}}</h4>
+			</div>
+		</div>
+		<div class="col-12">
+			<collection-component collection-id="{{$collection->id}}"></collection-component>
+		</div>
+	</div>
+</div>
+
+@endsection
+
+@push('styles')
+<style type="text/css">
+</style>
+@endpush
+@push('scripts')
+<script type="text/javascript" src="{{mix('js/compose.js')}}"></script>
+<script type="text/javascript" src="{{mix('js/collections.js')}}"></script>
+<script type="text/javascript">
+	new Vue({
+		el: '#content'
+	})
+</script>
+@endpush	

+ 10 - 0
routes/web.php

@@ -115,6 +115,13 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
             Route::get('discover/tag/list', 'HashtagFollowController@getTags');
             Route::get('profile/sponsor/{id}', 'ProfileSponsorController@get');
             Route::get('bookmarks', 'InternalApiController@bookmarks');
+            Route::get('collection/items/{id}', 'CollectionController@getItems');
+            Route::post('collection/item', 'CollectionController@storeId');
+            Route::get('collection/{id}', 'CollectionController@get');
+            Route::post('collection/{id}', 'CollectionController@store');
+            Route::delete('collection/{id}', 'CollectionController@delete');
+            Route::post('collection/{id}/publish', 'CollectionController@publish')->middleware('throttle:maxCollectionsPerHour,60')->middleware('throttle:maxCollectionsPerDay,1440')->middleware('throttle:maxCollectionsPerMonth,43800');
+            Route::get('profile/collections/{id}', 'CollectionController@getUserCollections');
         });
     });
 
@@ -167,6 +174,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
             Route::get('abusive/post', 'ReportController@abusivePostForm')->name('report.abusive.post');
             Route::get('abusive/profile', 'ReportController@abusiveProfileForm')->name('report.abusive.profile');
         });
+
+        Route::get('collections/create', 'CollectionController@create');
     });
 
     Route::group(['prefix' => 'account'], function () {
@@ -314,6 +323,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
         Route::get('{username}/following', 'FederationController@userFollowing');
     });
 
+    Route::get('c/{collection}', 'CollectionController@show');
     Route::get('p/{username}/{id}/c/{cid}', 'CommentController@show');
     Route::get('p/{username}/{id}/c', 'CommentController@showAll');
     Route::get('p/{username}/{id}/edit', 'StatusController@edit');

+ 3 - 0
webpack.mix.js

@@ -31,6 +31,9 @@ mix.js('resources/assets/js/app.js', 'public/js')
 // .js('resources/assets/js/embed.js', 'public')
 // .js('resources/assets/js/direct.js', 'public/js')
 .js('resources/assets/js/hashtag.js', 'public/js')
+.js('resources/assets/js/collectioncompose.js', 'public/js')
+.js('resources/assets/js/collections.js', 'public/js')
+
 .extract([
 	'lodash',
 	'popper.js',

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно