├── public ├── favicon.ico ├── robots.txt ├── mix-manifest.json ├── images │ ├── avatars │ │ ├── default.png │ │ └── default.svg │ ├── best-answer.svg │ ├── notifi.svg │ ├── search.svg │ ├── replies.svg │ ├── popular-thread.svg │ ├── visits.svg │ ├── unsw-thread.svg │ ├── all-threads.svg │ └── little-guy.svg ├── .htaccess ├── web.config ├── css │ └── vendor │ │ └── jquery.atwho.css └── index.php ├── database ├── .gitignore ├── seeds │ ├── DatabaseSeeder.php │ └── UsersSeeder.php └── migrations │ ├── 2014_10_12_100000_create_password_resets_table.php │ ├── 2017_03_14_180530_create_replies_table.php │ ├── 2017_05_23_214221_create_notifications_table.php │ ├── 2017_04_21_162429_create_activities_table.php │ ├── 2017_03_30_161938_create_favorites_table.php │ ├── 2018_01_29_141526_create_jobs_table.php │ ├── 2017_03_20_191601_create_channels_table.php │ ├── 2017_05_19_141013_create_thread_subscriptions_table.php │ ├── 2014_10_12_000000_create_users_table.php │ └── 2017_03_15_175959_create_threads_table.php ├── bootstrap ├── cache │ └── .gitignore ├── autoload.php └── app.php ├── storage ├── debugbar │ └── .gitignore ├── logs │ └── .gitignore ├── app │ ├── public │ │ └── .gitignore │ └── .gitignore └── framework │ ├── cache │ └── .gitignore │ ├── testing │ └── .gitignore │ ├── views │ └── .gitignore │ ├── sessions │ └── .gitignore │ └── .gitignore ├── .gitattributes ├── app ├── Exceptions │ └── ThrottleException.php ├── Http │ ├── Controllers │ │ ├── LeaderboardController.php │ │ ├── Api │ │ │ ├── LeaderboardController.php │ │ │ ├── ChannelsController.php │ │ │ ├── UsersController.php │ │ │ └── UserAvatarController.php │ │ ├── Admin │ │ │ └── DashboardController.php │ │ ├── Controller.php │ │ ├── SearchController.php │ │ ├── BestRepliesController.php │ │ ├── HomeController.php │ │ ├── LockedThreadsController.php │ │ ├── PinnedThreadsController.php │ │ ├── ThreadSubscriptionsController.php │ │ ├── Auth │ │ │ ├── RegisterConfirmationController.php │ │ │ ├── ForgotPasswordController.php │ │ │ ├── ResetPasswordController.php │ │ │ └── LoginController.php │ │ ├── ProfilesController.php │ │ ├── FavoritesController.php │ │ ├── UserNotificationsController.php │ │ └── RepliesController.php │ ├── Middleware │ │ ├── EncryptCookies.php │ │ ├── VerifyCsrfToken.php │ │ ├── TrimStrings.php │ │ ├── TrustProxies.php │ │ ├── LoadCommonData.php │ │ ├── Administrator.php │ │ ├── RedirectIfAuthenticated.php │ │ └── RedirectIfEmailNotConfirmed.php │ └── Requests │ │ └── CreatePostRequest.php ├── Providers │ ├── BroadcastServiceProvider.php │ ├── AppServiceProvider.php │ ├── AuthServiceProvider.php │ ├── EventServiceProvider.php │ └── RouteServiceProvider.php ├── Policies │ ├── UserPolicy.php │ ├── ThreadPolicy.php │ └── ReplyPolicy.php ├── Listeners │ ├── NotifySubscribers.php │ └── NotifyMentionedUsers.php ├── Favorite.php ├── Rules │ ├── SpamFree.php │ └── Recaptcha.php ├── Inspections │ ├── Spam.php │ └── InvalidKeywords.php ├── HasReputation.php ├── Events │ ├── ThreadWasPublished.php │ └── ThreadReceivedNewReply.php ├── Mail │ └── PleaseConfirmYourEmail.php ├── Console │ └── Kernel.php ├── ThreadSubscription.php ├── Filters │ ├── ThreadFilters.php │ └── Filters.php ├── Notifications │ ├── ThreadWasUpdated.php │ └── YouWereMentioned.php ├── Activity.php ├── Trending.php ├── RecordsActivity.php ├── Channel.php └── Favoritable.php ├── resources ├── views │ ├── svgs │ │ ├── icons │ │ │ ├── leaderboard.blade.php │ │ │ ├── best-reply.blade.php │ │ │ ├── alarm.blade.php │ │ │ ├── heart.blade.php │ │ │ ├── search.blade.php │ │ │ ├── book.blade.php │ │ │ ├── star.blade.php │ │ │ ├── eye.blade.php │ │ │ ├── question.blade.php │ │ │ └── all-threads.blade.php │ │ └── logo.blade.php │ ├── modals │ │ ├── all.blade.php │ │ └── login.blade.php │ ├── admin │ │ ├── dashboard │ │ │ └── index.blade.php │ │ ├── channels │ │ │ ├── create.blade.php │ │ │ ├── edit.blade.php │ │ │ ├── _form.blade.php │ │ │ └── index.blade.php │ │ └── layout │ │ │ └── app.blade.php │ ├── leaderboard │ │ └── index.blade.php │ ├── threads │ │ ├── index.blade.php │ │ ├── show.blade.php │ │ ├── reply.blade.php │ │ └── search.blade.php │ ├── profiles │ │ ├── show.blade.php │ │ └── activities │ │ │ ├── activity.blade.php │ │ │ ├── created_thread.blade.php │ │ │ ├── created_reply.blade.php │ │ │ └── created_favorite.blade.php │ ├── emails │ │ └── confirm-email.blade.php │ ├── home.blade.php │ ├── channels-sidebar.blade.php │ ├── vendor │ │ └── pagination │ │ │ ├── simple-default.blade.php │ │ │ ├── simple-bootstrap-4.blade.php │ │ │ ├── semantic-ui.blade.php │ │ │ ├── bootstrap-4.blade.php │ │ │ └── default.blade.php │ ├── layouts │ │ ├── base.blade.php │ │ └── app.blade.php │ ├── breadcrumbs.blade.php │ └── auth │ │ └── passwords │ │ └── email.blade.php ├── assets │ ├── sass │ │ ├── components │ │ │ ├── widget.scss │ │ │ └── _btn.scss │ │ ├── sections │ │ │ ├── search.scss │ │ │ └── _trix.scss │ │ ├── _timeline.scss │ │ ├── app.scss │ │ └── _variables.scss │ └── js │ │ ├── authorizations.js │ │ ├── mixins │ │ ├── collection.js │ │ └── activation.js │ │ ├── components │ │ ├── LogoutButton.vue │ │ ├── ImageUpload.vue │ │ ├── Dropdown.vue │ │ ├── ActivityLayout.vue │ │ ├── SubscribeButton.vue │ │ ├── Register.vue │ │ ├── Highlight.vue │ │ ├── Wysiwyg.vue │ │ ├── Login.vue │ │ ├── ActivityFavorite.vue │ │ ├── Paginator.vue │ │ ├── Leaderboard.vue │ │ ├── AvatarForm.vue │ │ ├── Replies.vue │ │ ├── Flash.vue │ │ ├── Favorite.vue │ │ ├── Activities.vue │ │ └── ChannelDropdown.vue │ │ └── app.js └── lang │ └── en │ ├── pagination.php │ ├── auth.php │ └── passwords.php ├── .gitignore ├── .travis.yml ├── webpack.mix.js ├── tests ├── utilities │ └── functions.php ├── Unit │ ├── SpamTest.php │ ├── UserTest.php │ ├── ActivityTest.php │ ├── ChannelTest.php │ └── TrendingTest.php ├── Feature │ ├── ProfilesTest.php │ ├── TrendingThreadsTest.php │ ├── Admin │ │ └── AdministratorTest.php │ ├── SearchTest.php │ ├── SubscribeToThreadsTest.php │ ├── LeaderboardTest.php │ ├── AddAvatarTest.php │ ├── UpdateThreadsTest.php │ ├── FavoritesTest.php │ ├── BestReplyTest.php │ ├── LockThreadsTest.php │ └── NotificationsTest.php ├── CreatesApplication.php └── TestCase.php ├── .env.travis ├── config ├── council.php ├── hashing.php ├── view.php ├── services.php ├── broadcasting.php └── logging.php ├── CODE_OF_CONDUCT.md ├── routes ├── channels.php ├── api.php └── console.php ├── server.php ├── .env.example ├── LICENSE.md ├── readme.md ├── package.json ├── phpunit.xml ├── artisan └── composer.json /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite 2 | -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/debugbar/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /storage/app/public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !public/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/testing/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/sessions/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.css linguist-vendored 3 | *.scss linguist-vendored 4 | -------------------------------------------------------------------------------- /public/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/app.js": "/js/app.js", 3 | "/css/app.css": "/css/app.css" 4 | } -------------------------------------------------------------------------------- /public/images/avatars/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JeffreyWay/council/HEAD/public/images/avatars/default.png -------------------------------------------------------------------------------- /app/Exceptions/ThrottleException.php: -------------------------------------------------------------------------------- 1 | 2 |
LB
3 | -------------------------------------------------------------------------------- /resources/views/modals/all.blade.php: -------------------------------------------------------------------------------- 1 | @includeWhen(auth()->check() && auth()->user()->confirmed, 'modals.new-thread') 2 | @include('modals.login') 3 | @include('modals.register') 4 | -------------------------------------------------------------------------------- /resources/views/admin/dashboard/index.blade.php: -------------------------------------------------------------------------------- 1 | @extends('admin.layout.app') 2 | 3 | @section('administration-content') 4 |

You are on the administration dashboard.

5 | @endsection 6 | -------------------------------------------------------------------------------- /resources/views/leaderboard/index.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('content') 4 |

Leaderboard

5 | 6 | @endsection 7 | -------------------------------------------------------------------------------- /resources/assets/sass/components/widget.scss: -------------------------------------------------------------------------------- 1 | .widget { 2 | @apply .mb-4 .pb-4; 3 | 4 | > .widget-heading { 5 | @apply .mb-2 .pb-2 .text-xs .uppercase .text-grey-dark .tracking-wide; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /public/storage 3 | /public/hot 4 | /public/css/app.css 5 | /public/fonts 6 | /public/js/app.js 7 | /storage/*.key 8 | /storage/purify 9 | /vendor 10 | /.idea 11 | Homestead.json 12 | Homestead.yaml 13 | .env -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.1 5 | - 7.2 6 | 7 | before_script: 8 | - cp .env.travis .env 9 | - composer install --no-interaction 10 | - php artisan key:generate 11 | 12 | cache: 13 | directories: 14 | - vendor 15 | -------------------------------------------------------------------------------- /resources/assets/sass/sections/search.scss: -------------------------------------------------------------------------------- 1 | .search-wrap:hover svg { 2 | z-index: 20; 3 | } 4 | 5 | .search-input { 6 | right: 17px; 7 | border-top-left-radius: 20px; 8 | border-bottom-left-radius: 20px; 9 | outline: none; 10 | } 11 | -------------------------------------------------------------------------------- /app/Http/Controllers/LeaderboardController.php: -------------------------------------------------------------------------------- 1 | 5 | @include ('admin.channels._form') 6 | 7 | @endsection 8 | -------------------------------------------------------------------------------- /resources/views/threads/index.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('content') 4 | @include('breadcrumbs') 5 | 6 |
7 | @include ('threads._list') 8 | 9 | {{ $threads->render() }} 10 |
11 | @endsection 12 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | let mix = require('laravel-mix'); 2 | 3 | require('laravel-mix-tailwind'); 4 | 5 | mix 6 | .js('resources/assets/js/app.js', 'js') 7 | .sass('resources/assets/sass/app.scss', 'public/css') 8 | .tailwind() 9 | .browserSync('council.test'); 10 | -------------------------------------------------------------------------------- /resources/assets/js/authorizations.js: -------------------------------------------------------------------------------- 1 | let user = window.App.user; 2 | 3 | module.exports = { 4 | owns (model, prop = 'user_id') { 5 | return parseInt(model[prop]) === user.id; 6 | }, 7 | 8 | isAdmin() { 9 | return user.isAdmin; 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /resources/views/profiles/show.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('content') 4 |
5 | 6 |
7 | 8 | 9 | @endsection 10 | -------------------------------------------------------------------------------- /tests/utilities/functions.php: -------------------------------------------------------------------------------- 1 | create($attributes); 6 | } 7 | 8 | function make($class, $attributes = [], $times = null) 9 | { 10 | return factory($class, $times)->make($attributes); 11 | } 12 | -------------------------------------------------------------------------------- /.env.travis: -------------------------------------------------------------------------------- 1 | APP_ENV=testing 2 | APP_KEY= 3 | 4 | CACHE_DRIVER=array 5 | SESSION_DRIVER=array 6 | QUEUE_DRIVER=sync 7 | 8 | RECAPTCHA_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI 9 | RECAPTCHA_SECRET=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe 10 | 11 | SCOUT_DRIVER=null 12 | ALGOLIA_APP_ID= 13 | ALGOLIA_KEY= 14 | ALGOLIA_SECRET= -------------------------------------------------------------------------------- /public/images/best-answer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/views/admin/channels/edit.blade.php: -------------------------------------------------------------------------------- 1 | @extends('admin.layout.app') 2 | 3 | @section('administration-content') 4 |
5 | {{ method_field('PATCH') }} 6 | @include ('admin.channels._form', ['buttonText' => 'Update Channel']) 7 |
8 | @endsection 9 | -------------------------------------------------------------------------------- /resources/views/profiles/activities/activity.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | {{ $heading }} 6 | 7 |
8 |
9 | 10 |
11 | {{ $body }} 12 |
13 |
14 | -------------------------------------------------------------------------------- /resources/assets/sass/sections/_trix.scss: -------------------------------------------------------------------------------- 1 | #app trix-editor { 2 | border-color: #dae1e7; 3 | border-radius: 0; 4 | @apply .p-2; 5 | } 6 | 7 | #app trix-toolbar .trix-button-group { 8 | border-color: #dae1e7; 9 | margin-bottom: 0; 10 | border-bottom: none; 11 | border-radius: 0; 12 | } 13 | 14 | #app trix-toolbar .trix-button { 15 | border-bottom: none; 16 | } 17 | -------------------------------------------------------------------------------- /resources/views/svgs/icons/best-reply.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/images/notifi.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /resources/views/profiles/activities/created_thread.blade.php: -------------------------------------------------------------------------------- 1 | @component('profiles.activities.activity') 2 | @slot('heading') 3 | {{ $profileUser->username }} published 4 | {{ $activity->subject->title }} 5 | @endslot 6 | 7 | @slot('body') 8 | {!! $activity->subject->body !!} 9 | @endslot 10 | @endcomponent 11 | -------------------------------------------------------------------------------- /database/seeds/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | call([ 15 | UsersSeeder::class, 16 | SampleDataSeeder::class 17 | ]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/Http/Middleware/EncryptCookies.php: -------------------------------------------------------------------------------- 1 | usernname }} replied to 4 | "{{ $activity->subject->thread->title }}" 5 | @endslot 6 | 7 | @slot('body') 8 | {!! $activity->subject->body !!} 9 | @endslot 10 | @endcomponent 11 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/LeaderboardController.php: -------------------------------------------------------------------------------- 1 | User::limit(10)->orderBy('reputation', 'desc')->get() 14 | ]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /resources/views/emails/confirm-email.blade.php: -------------------------------------------------------------------------------- 1 | @component('mail::message') 2 | # One Last Step 3 | 4 | We just need you to confirm your email address to prove that you're a human. You get it, right? Coo. 5 | 6 | @component('mail::button', ['url' => url('/register/confirm?token=' . $user->confirmation_token)]) 7 | Confirm Email 8 | @endcomponent 9 | 10 | Thanks,
11 | {{ config('app.name') }} 12 | @endcomponent 13 | -------------------------------------------------------------------------------- /resources/views/profiles/activities/created_favorite.blade.php: -------------------------------------------------------------------------------- 1 | @component('profiles.activities.activity') 2 | @slot('heading') 3 | 4 | {{ $profileUser->username }} favorited a reply. 5 | 6 | @endslot 7 | 8 | @slot('body') 9 | {!! $activity->subject->favorited->body !!} 10 | @endslot 11 | @endcomponent 12 | -------------------------------------------------------------------------------- /app/Http/Middleware/VerifyCsrfToken.php: -------------------------------------------------------------------------------- 1 | [ 5 | // Add the email addresses of users who should be administrators here. 6 | ], 7 | 8 | 'reputation' => [ 9 | 'thread_published' => 10, 10 | 'reply_posted' => 2, 11 | 'best_reply_awarded' => 50, 12 | 'reply_favorited' => 5 13 | ], 14 | 15 | 'pagination' => [ 16 | 'perPage' => 25 17 | ] 18 | ]; 19 | -------------------------------------------------------------------------------- /resources/views/svgs/icons/alarm.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/images/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/Http/Controllers/Admin/DashboardController.php: -------------------------------------------------------------------------------- 1 | rememberForever('channels', function () { 16 | return Channel::all(); 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /resources/assets/sass/_timeline.scss: -------------------------------------------------------------------------------- 1 | .timeline { 2 | .entry { 3 | clear: both; 4 | text-align: left; 5 | position: relative; 6 | .title { 7 | @apply absolute float-left text-right; 8 | &:before { 9 | content: ''; 10 | @apply absolute h-6 w-6 rounded-full bg-blue-darker border-4 border-blue-darker; 11 | top: -1.5rem; 12 | right: calc(-.25rem - 2px); 13 | z-index: 99; 14 | } 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /resources/views/svgs/icons/heart.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /resources/views/svgs/icons/search.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/Unit/SpamTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($spam->detect('Innocent reply here')); 16 | 17 | $this->expectException('Exception'); 18 | 19 | $spam->detect('yahoo customer support'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/Http/Controllers/SearchController.php: -------------------------------------------------------------------------------- 1 | expectsJson()) { 17 | return Thread::search(request('q'))->paginate(25); 18 | } 19 | 20 | return view('threads.search'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/Providers/BroadcastServiceProvider.php: -------------------------------------------------------------------------------- 1 | 5 |
6 |
7 |
8 |
Dashboard
9 | 10 |
11 | You are logged in! 12 |
13 |
14 |
15 |
16 | 17 | @endsection 18 | -------------------------------------------------------------------------------- /public/images/replies.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | This code of conduct is derived from the Ruby code of conduct. Any violations of the code of conduct may be reported to Jeffrey Way (jeffrey@laracasts.com). 2 | 3 | - Participants will be tolerant of opposing views. 4 | 5 | - Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks. 6 | 7 | - When interpreting the words and actions of others, participants should always assume good intentions. 8 | 9 | - Behavior which can be reasonably considered harassment will not be tolerated. -------------------------------------------------------------------------------- /app/Http/Controllers/BestRepliesController.php: -------------------------------------------------------------------------------- 1 | authorize('update', $reply->thread); 18 | 19 | $reply->thread->markBestReply($reply); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /public/images/popular-thread.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/UsersController.php: -------------------------------------------------------------------------------- 1 | take(5) 21 | ->pluck('username'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Feature/ProfilesTest.php: -------------------------------------------------------------------------------- 1 | getJson("/profiles/{$user->username}")->json(); 18 | 19 | $this->assertEquals($response['profileUser']['name'], $user->name); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/Http/Middleware/TrustProxies.php: -------------------------------------------------------------------------------- 1 | id === $user->id; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /resources/views/svgs/icons/book.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/Http/Controllers/HomeController.php: -------------------------------------------------------------------------------- 1 | middleware('auth'); 15 | } 16 | 17 | /** 18 | * Show the application dashboard. 19 | * 20 | * @return \Illuminate\Http\Response 21 | */ 22 | public function index() 23 | { 24 | return view('home'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/Listeners/NotifySubscribers.php: -------------------------------------------------------------------------------- 1 | reply->thread->subscriptions 18 | ->where('user_id', '!=', $event->reply->user_id) 19 | ->each 20 | ->notify($event->reply); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/Policies/ThreadPolicy.php: -------------------------------------------------------------------------------- 1 | user_id == $user->id; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /resources/views/svgs/icons/star.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/Favorite.php: -------------------------------------------------------------------------------- 1 | morphTo(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /routes/channels.php: -------------------------------------------------------------------------------- 1 | id === (int) $id; 16 | }); 17 | -------------------------------------------------------------------------------- /app/Rules/SpamFree.php: -------------------------------------------------------------------------------- 1 | detect($value); 21 | } catch (Exception $e) { 22 | return false; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/CreatesApplication.php: -------------------------------------------------------------------------------- 1 | make(Kernel::class)->bootstrap(); 20 | 21 | Hash::driver('bcrypt')->setRounds(4); 22 | 23 | return $app; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /resources/assets/js/components/LogoutButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 24 | -------------------------------------------------------------------------------- /resources/views/threads/show.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('head') 4 | 5 | @endsection 6 | 7 | @section('content') 8 | 9 |
10 | @include('breadcrumbs') 11 | 12 |
13 | @include ('threads._question') 14 | 15 | 16 |
17 |
18 |
19 | @endsection 20 | -------------------------------------------------------------------------------- /app/Http/Middleware/LoadCommonData.php: -------------------------------------------------------------------------------- 1 | share('channels', \App\Channel::all()); 20 | view()->share('trending', app(Trending::class)->get()); 21 | 22 | return $next($request); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | get('/user', function (Request $request) { 17 | return $request->user(); 18 | }); 19 | -------------------------------------------------------------------------------- /app/Http/Middleware/Administrator.php: -------------------------------------------------------------------------------- 1 | check() && auth()->user()->isAdmin()) { 19 | return $next($request); 20 | } 21 | 22 | abort(403, 'You do not have permission to perform this action.'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Inspections/Spam.php: -------------------------------------------------------------------------------- 1 | inspections as $inspection) { 25 | app($inspection)->detect($body); 26 | } 27 | 28 | return false; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /config/hashing.php: -------------------------------------------------------------------------------- 1 | 'bcrypt', 19 | 20 | ]; 21 | -------------------------------------------------------------------------------- /server.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | $uri = urldecode( 9 | parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) 10 | ); 11 | 12 | // This file allows us to emulate Apache's "mod_rewrite" functionality from the 13 | // built-in PHP web server. This provides a convenient way to test a Laravel 14 | // application without having installed a "real" web server software here. 15 | if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) { 16 | return false; 17 | } 18 | 19 | require_once __DIR__.'/public/index.php'; 20 | -------------------------------------------------------------------------------- /public/images/visits.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /resources/lang/en/pagination.php: -------------------------------------------------------------------------------- 1 | '« Previous', 17 | 'next' => 'Next »', 18 | 19 | ]; 20 | -------------------------------------------------------------------------------- /routes/console.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 18 | })->describe('Display an inspiring quote'); 19 | -------------------------------------------------------------------------------- /bootstrap/autoload.php: -------------------------------------------------------------------------------- 1 | increment('reputation', config("council.reputation.{$action}")); 15 | } 16 | 17 | /** 18 | * Reduce reputation points for the model. 19 | * 20 | * @param string $action 21 | */ 22 | public function loseReputation($action) 23 | { 24 | $this->decrement('reputation', config("council.reputation.{$action}")); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Redirect Trailing Slashes If Not A Folder... 9 | RewriteCond %{REQUEST_FILENAME} !-d 10 | RewriteRule ^(.*)/$ /$1 [L,R=301] 11 | 12 | # Handle Front Controller... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_FILENAME} !-f 15 | RewriteRule ^ index.php [L] 16 | 17 | # Handle Authorization Header 18 | RewriteCond %{HTTP:Authorization} . 19 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 20 | 21 | -------------------------------------------------------------------------------- /app/Http/Controllers/LockedThreadsController.php: -------------------------------------------------------------------------------- 1 | update(['locked' => true]); 17 | } 18 | 19 | /** 20 | * Unlock the given thread. 21 | * 22 | * @param \App\Thread $thread 23 | */ 24 | public function destroy(Thread $thread) 25 | { 26 | $thread->update(['locked' => false]); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/Http/Controllers/PinnedThreadsController.php: -------------------------------------------------------------------------------- 1 | update(['pinned' => true]); 17 | } 18 | 19 | /** 20 | * Un-Pin the given thread. 21 | * 22 | * @param \App\Thread $thread 23 | */ 24 | public function destroy(Thread $thread) 25 | { 26 | $thread->update(['pinned' => false]); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /resources/assets/js/mixins/activation.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data() { 3 | return { 4 | active: false, 5 | timer: null 6 | }; 7 | }, 8 | 9 | methods: { 10 | activate() { 11 | window.clearTimeout(this.timer); 12 | 13 | this.timer = window.setTimeout(() => { 14 | this.active = true; 15 | }, 100); 16 | }, 17 | 18 | deactivate() { 19 | window.clearTimeout(this.timer); 20 | 21 | this.timer = window.setTimeout(() => { 22 | this.active = false; 23 | }, 100); 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /app/Http/Middleware/RedirectIfAuthenticated.php: -------------------------------------------------------------------------------- 1 | check()) { 21 | return redirect('/home'); 22 | } 23 | 24 | return $next($request); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /resources/views/svgs/icons/eye.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /resources/assets/sass/app.scss: -------------------------------------------------------------------------------- 1 | @tailwind preflight; 2 | 3 | @import "components/btn"; 4 | @import "components/widget"; 5 | 6 | @import "sections/search"; 7 | @import "sections/trix"; 8 | 9 | @import "timeline"; 10 | 11 | @tailwind utilities; 12 | 13 | a { 14 | @apply .text-grey-darkest; 15 | text-decoration: none; 16 | 17 | &.link:hover { 18 | text-decoration: underline; 19 | } 20 | } 21 | 22 | input { 23 | @apply .border .border-grey-light; 24 | } 25 | 26 | pre { 27 | @apply .whitespace-pre-wrap; 28 | } 29 | 30 | [v-cloak] { 31 | display: none; 32 | } 33 | 34 | .ais-highlight > em { 35 | font-weight: bold; 36 | @apply .text-black; 37 | } 38 | -------------------------------------------------------------------------------- /resources/views/channels-sidebar.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Channels

4 | 5 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /resources/assets/js/components/ImageUpload.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 26 | -------------------------------------------------------------------------------- /resources/lang/en/auth.php: -------------------------------------------------------------------------------- 1 | 'These credentials do not match our records.', 17 | 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 18 | 19 | ]; 20 | -------------------------------------------------------------------------------- /app/Http/Middleware/RedirectIfEmailNotConfirmed.php: -------------------------------------------------------------------------------- 1 | user(); 19 | 20 | if (! $user->confirmed && ! $user->isAdmin()) { 21 | return redirect('/threads') 22 | ->with('flash', 'You must first confirm your email address.'); 23 | } 24 | 25 | return $next($request); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/Inspections/InvalidKeywords.php: -------------------------------------------------------------------------------- 1 | keywords as $keyword) { 27 | if (stripos($body, $keyword) !== false) { 28 | throw new Exception('Your reply contains spam.'); 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->isLocal()) { 27 | $this->app->register(\Barryvdh\Debugbar\ServiceProvider::class); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Events/ThreadWasPublished.php: -------------------------------------------------------------------------------- 1 | thread = $thread; 27 | } 28 | 29 | /** 30 | * Get the subject of the event. 31 | */ 32 | public function subject() 33 | { 34 | return $this->thread; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/Http/Controllers/ThreadSubscriptionsController.php: -------------------------------------------------------------------------------- 1 | subscribe(); 18 | } 19 | 20 | /** 21 | * Delete an existing thread subscription. 22 | * 23 | * @param int $channelId 24 | * @param Thread $thread 25 | */ 26 | public function destroy($channelId, Thread $thread) 27 | { 28 | $thread->unsubscribe(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /resources/views/vendor/pagination/simple-default.blade.php: -------------------------------------------------------------------------------- 1 | @if ($paginator->hasPages()) 2 | 17 | @endif 18 | -------------------------------------------------------------------------------- /app/Events/ThreadReceivedNewReply.php: -------------------------------------------------------------------------------- 1 | reply = $reply; 27 | } 28 | 29 | /** 30 | * Get the subject of the event. 31 | */ 32 | public function subject() 33 | { 34 | return $this->reply; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/UserAvatarController.php: -------------------------------------------------------------------------------- 1 | validate([ 18 | 'avatar' => ['required', 'image'] 19 | ]); 20 | 21 | Storage::disk('public')->delete(auth()->user()->getOriginal('avatar_path')); 22 | 23 | auth()->user()->update([ 24 | 'avatar_path' => request()->file('avatar')->store('avatars', 'public') 25 | ]); 26 | 27 | return response([], 204); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /resources/assets/js/components/Dropdown.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 28 | -------------------------------------------------------------------------------- /resources/assets/js/components/ActivityLayout.vue: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/RegisterConfirmationController.php: -------------------------------------------------------------------------------- 1 | first(); 18 | 19 | if (! $user) { 20 | return redirect(route('threads'))->with('flash', 'Unknown token.'); 21 | } 22 | 23 | $user->confirm(); 24 | 25 | return redirect(route('threads')) 26 | ->with('flash', 'Your account is now confirmed! You may post to the forum.'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /resources/assets/js/components/SubscribeButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 30 | -------------------------------------------------------------------------------- /app/Http/Controllers/ProfilesController.php: -------------------------------------------------------------------------------- 1 | Activity::feed($user) 19 | ]; 20 | } 21 | 22 | /** 23 | * Show the user's profile. 24 | * 25 | * @param User $user 26 | * @return \Response 27 | */ 28 | public function show(User $user) 29 | { 30 | $data = ['profileUser' => $user]; 31 | 32 | if (request()->expectsJson()) { 33 | return $data; 34 | } 35 | 36 | return view('profiles.show', $data); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_ENV=local 2 | APP_KEY= 3 | APP_DEBUG=true 4 | APP_URL=http://council.test 5 | 6 | LOG_CHANNEL=stack 7 | 8 | DB_CONNECTION=mysql 9 | DB_HOST=127.0.0.1 10 | DB_PORT=3306 11 | DB_DATABASE=council 12 | DB_USERNAME=root 13 | DB_PASSWORD= 14 | 15 | BROADCAST_DRIVER=log 16 | CACHE_DRIVER=file 17 | SESSION_DRIVER=file 18 | QUEUE_DRIVER=sync 19 | 20 | REDIS_HOST=127.0.0.1 21 | REDIS_PASSWORD=null 22 | REDIS_PORT=6379 23 | 24 | MAIL_DRIVER=log 25 | MAIL_HOST=mailtrap.io 26 | MAIL_PORT=2525 27 | MAIL_USERNAME= 28 | MAIL_PASSWORD= 29 | MAIL_ENCRYPTION=null 30 | 31 | PUSHER_APP_ID= 32 | PUSHER_APP_KEY= 33 | PUSHER_APP_SECRET= 34 | 35 | RECAPTCHA_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI 36 | RECAPTCHA_SECRET=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe 37 | 38 | SCOUT_DRIVER=null 39 | SCOUT_QUEUE=true 40 | ALGOLIA_APP_ID= 41 | ALGOLIA_KEY= 42 | ALGOLIA_SECRET= 43 | -------------------------------------------------------------------------------- /public/images/unsw-thread.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_100000_create_password_resets_table.php: -------------------------------------------------------------------------------- 1 | string('email')->index(); 18 | $table->string('token')->index(); 19 | $table->timestamp('created_at')->nullable(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::dropIfExists('password_resets'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Listeners/NotifyMentionedUsers.php: -------------------------------------------------------------------------------- 1 | subject(), function ($subject) { 19 | User::whereIn('username', $this->mentionedUsers($subject)) 20 | ->get()->each->notify(new YouWereMentioned($subject)); 21 | }); 22 | } 23 | 24 | /** 25 | * Fetch all mentioned users within the reply's body. 26 | * 27 | * @return array 28 | */ 29 | public function mentionedUsers($body) 30 | { 31 | preg_match_all('/@([\w\-]+)/', $body, $matches); 32 | 33 | return $matches[1]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /resources/lang/en/passwords.php: -------------------------------------------------------------------------------- 1 | 'Passwords must be at least six characters and match the confirmation.', 17 | 'reset' => 'Your password has been reset!', 18 | 'sent' => 'We have e-mailed your password reset link!', 19 | 'token' => 'This password reset token is invalid.', 20 | 'user' => "We can't find a user with that e-mail address.", 21 | 22 | ]; 23 | -------------------------------------------------------------------------------- /resources/views/vendor/pagination/simple-bootstrap-4.blade.php: -------------------------------------------------------------------------------- 1 | @if ($paginator->hasPages()) 2 | 17 | @endif 18 | -------------------------------------------------------------------------------- /tests/Feature/TrendingThreadsTest.php: -------------------------------------------------------------------------------- 1 | trending = new Trending(); 18 | 19 | $this->trending->reset(); 20 | } 21 | 22 | /** @test */ 23 | public function it_increments_a_threads_score_each_time_it_is_read() 24 | { 25 | $this->assertEmpty($this->trending->get()); 26 | 27 | $thread = create(\App\Thread::class); 28 | 29 | $this->call('GET', $thread->path()); 30 | 31 | $this->assertCount(1, $trending = $this->trending->get()); 32 | 33 | $this->assertEquals($thread->title, $trending[0]->title); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /database/migrations/2017_03_14_180530_create_replies_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->integer('thread_id'); 19 | $table->integer('user_id'); 20 | $table->text('body'); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('replies'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/Http/Controllers/FavoritesController.php: -------------------------------------------------------------------------------- 1 | middleware('auth'); 15 | } 16 | 17 | /** 18 | * Store a new favorite in the database. 19 | * 20 | * @param Reply $reply 21 | */ 22 | public function store(Reply $reply) 23 | { 24 | $reply->favorite(); 25 | 26 | $reply->owner->gainReputation('reply_favorited'); 27 | } 28 | 29 | /** 30 | * Delete the favorite. 31 | * 32 | * @param Reply $reply 33 | */ 34 | public function destroy(Reply $reply) 35 | { 36 | $reply->unfavorite(); 37 | 38 | $reply->owner->loseReputation('reply_favorited'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /resources/views/svgs/icons/question.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/Mail/PleaseConfirmYourEmail.php: -------------------------------------------------------------------------------- 1 | user = $user; 29 | } 30 | 31 | /** 32 | * Build the email. 33 | * 34 | * @return $this 35 | */ 36 | public function build() 37 | { 38 | return $this->markdown('emails.confirm-email'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Unit/UserTest.php: -------------------------------------------------------------------------------- 1 | $user->id]); 18 | 19 | $this->assertEquals($reply->id, $user->lastReply->id); 20 | } 21 | 22 | /** @test */ 23 | public function a_user_can_determine_their_avatar_path() 24 | { 25 | $user = create(\App\User::class); 26 | 27 | $this->assertEquals(asset('images/avatars/default.svg'), $user->avatar_path); 28 | 29 | $user->avatar_path = 'avatars/me.jpg'; 30 | 31 | $this->assertEquals(asset('avatars/me.jpg'), $user->avatar_path); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /resources/views/svgs/logo.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /resources/assets/js/components/Register.vue: -------------------------------------------------------------------------------- 1 | 36 | -------------------------------------------------------------------------------- /app/Providers/AuthServiceProvider.php: -------------------------------------------------------------------------------- 1 | \App\Policies\ThreadPolicy::class, 16 | \App\Reply::class => \App\Policies\ReplyPolicy::class, 17 | \App\User::class => \App\Policies\UserPolicy::class, 18 | ]; 19 | 20 | /** 21 | * Register any authentication / authorization services. 22 | * 23 | * @return void 24 | */ 25 | public function boot() 26 | { 27 | $this->registerPolicies(); 28 | 29 | // Gate::before(function ($user) { 30 | // if ($user->name === 'John Doe') return true; 31 | // }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/Providers/EventServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 16 | \App\Listeners\NotifyMentionedUsers::class, 17 | \App\Listeners\NotifySubscribers::class 18 | ], 19 | 20 | \App\Events\ThreadWasPublished::class => [ 21 | \App\Listeners\NotifyMentionedUsers::class 22 | ], 23 | ]; 24 | 25 | /** 26 | * Register any events for your application. 27 | * 28 | * @return void 29 | */ 30 | public function boot() 31 | { 32 | parent::boot(); 33 | 34 | // 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /resources/assets/js/components/Highlight.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 37 | -------------------------------------------------------------------------------- /tests/Feature/Admin/AdministratorTest.php: -------------------------------------------------------------------------------- 1 | withExceptionHandling() 17 | ->signInAdmin() 18 | ->get(route('admin.dashboard.index')) 19 | ->assertStatus(Response::HTTP_OK); 20 | } 21 | 22 | /** @test */ 23 | public function a_non_administrator_cannot_access_the_administration_section() 24 | { 25 | $this->withExceptionHandling() 26 | ->actingAs(create(\App\User::class)) 27 | ->get(route('admin.dashboard.index')) 28 | ->assertStatus(Response::HTTP_FORBIDDEN); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /resources/views/admin/layout/app.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('sidebar') 4 | 19 | @endsection 20 | 21 | @section('content') 22 |
23 | @yield('administration-content') 24 |
25 | @endsection 26 | -------------------------------------------------------------------------------- /app/Policies/ReplyPolicy.php: -------------------------------------------------------------------------------- 1 | user_id == $user->id; 23 | } 24 | 25 | /** 26 | * Determine if the authenticated user has permission to create a new reply. 27 | * 28 | * @param User $user 29 | * @return bool 30 | */ 31 | public function create(User $user) 32 | { 33 | if (! $lastReply = $user->fresh()->lastReply) { 34 | return true; 35 | } 36 | 37 | return ! $lastReply->wasJustPublished(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /database/migrations/2017_05_23_214221_create_notifications_table.php: -------------------------------------------------------------------------------- 1 | uuid('id')->primary(); 18 | $table->string('type'); 19 | $table->morphs('notifiable'); 20 | $table->text('data'); 21 | $table->timestamp('read_at')->nullable(); 22 | $table->timestamps(); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | * 29 | * @return void 30 | */ 31 | public function down() 32 | { 33 | Schema::dropIfExists('notifications'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /database/migrations/2017_04_21_162429_create_activities_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->unsignedInteger('user_id')->index(); 19 | $table->unsignedInteger('subject_id')->index(); 20 | $table->string('subject_type', 50); 21 | $table->string('type', 50); 22 | $table->timestamps(); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | * 29 | * @return void 30 | */ 31 | public function down() 32 | { 33 | Schema::dropIfExists('activities'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/web.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/ForgotPasswordController.php: -------------------------------------------------------------------------------- 1 | middleware('guest'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /public/images/all-threads.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/Console/Kernel.php: -------------------------------------------------------------------------------- 1 | command('inspire') 28 | // ->hourly(); 29 | } 30 | 31 | /** 32 | * Register the Closure based commands for the application. 33 | * 34 | * @return void 35 | */ 36 | protected function commands() 37 | { 38 | require base_path('routes/console.php'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /database/migrations/2017_03_30_161938_create_favorites_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->unsignedInteger('user_id'); 19 | $table->unsignedInteger('favorited_id'); 20 | $table->string('favorited_type', 50); 21 | $table->timestamps(); 22 | 23 | $table->unique(['user_id', 'favorited_id', 'favorited_type']); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('favorites'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /resources/views/svgs/icons/all-threads.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/Http/Controllers/UserNotificationsController.php: -------------------------------------------------------------------------------- 1 | middleware('auth'); 13 | } 14 | 15 | /** 16 | * Fetch all unread notifications for the user. 17 | * 18 | * @return mixed 19 | */ 20 | public function index() 21 | { 22 | return auth()->user()->unreadNotifications; 23 | } 24 | 25 | /** 26 | * Mark a specific notification as read. 27 | * 28 | * @param \App\User $user 29 | * @param int $notificationId 30 | */ 31 | public function destroy($user, $notificationId) 32 | { 33 | $notification = auth()->user()->notifications()->findOrFail($notificationId); 34 | 35 | $notification->markAsRead(); 36 | 37 | return json_encode( 38 | $notification->data 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Feature/SearchTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped("Algolia is not configured."); 18 | } 19 | 20 | config(['scout.driver' => 'algolia']); 21 | 22 | create(\App\Thread::class, [], 2); 23 | create(\App\Thread::class, ['body' => 'A thread with the foobar term.'], 2); 24 | 25 | do { 26 | // Account for latency. 27 | sleep(.25); 28 | 29 | $results = $this->getJson('/threads/search?q=foobar')->json()['data']; 30 | } while (empty($results)); 31 | 32 | $this->assertCount(2, $results); 33 | 34 | // Clean up. 35 | Thread::latest()->take(4)->unsearchable(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright Jeffrey Way 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /tests/Feature/SubscribeToThreadsTest.php: -------------------------------------------------------------------------------- 1 | signIn(); 16 | 17 | // Given we have a thread... 18 | $thread = create(\App\Thread::class); 19 | 20 | // And the user subscribes to the thread... 21 | $this->post($thread->path() . '/subscriptions'); 22 | 23 | $this->assertCount(1, $thread->fresh()->subscriptions); 24 | } 25 | 26 | /** @test */ 27 | public function a_user_can_unsubscribe_from_threads() 28 | { 29 | $this->signIn(); 30 | 31 | $thread = create(\App\Thread::class); 32 | 33 | $thread->subscribe(); 34 | 35 | $this->delete($thread->path() . '/subscriptions'); 36 | 37 | $this->assertCount(0, $thread->subscriptions); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /database/migrations/2018_01_29_141526_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->string('queue')->index(); 19 | $table->longText('payload'); 20 | $table->unsignedTinyInteger('attempts'); 21 | $table->unsignedInteger('reserved_at')->nullable(); 22 | $table->unsignedInteger('available_at'); 23 | $table->unsignedInteger('created_at'); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('jobs'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /resources/assets/sass/_variables.scss: -------------------------------------------------------------------------------- 1 | 2 | // Body 3 | $body-bg: #f5f8fa; 4 | 5 | // Borders 6 | $laravel-border-color: darken($body-bg, 10%); 7 | $list-group-border: $laravel-border-color; 8 | $navbar-default-border: $laravel-border-color; 9 | $panel-default-border: $laravel-border-color; 10 | $panel-inner-border: $laravel-border-color; 11 | 12 | // Brands 13 | $brand-primary: #3097D1; 14 | $brand-info: #8eb4cb; 15 | $brand-success: #2ab27b; 16 | $brand-warning: #cbb956; 17 | $brand-danger: #bf5329; 18 | 19 | // Typography 20 | $icon-font-path: "~bootstrap-sass/assets/fonts/bootstrap/"; 21 | $font-family-sans-serif: "Raleway", sans-serif; 22 | $font-size-base: 14px; 23 | $line-height-base: 1.6; 24 | $text-color: #636b6f; 25 | 26 | // Navbar 27 | $navbar-default-bg: #fff; 28 | 29 | // Buttons 30 | $btn-default-color: $text-color; 31 | 32 | // Inputs 33 | $input-border: lighten($text-color, 40%); 34 | $input-border-focus: lighten($brand-primary, 25%); 35 | $input-color-placeholder: lighten($text-color, 30%); 36 | 37 | // Panels 38 | $panel-default-heading-bg: #fff; 39 | -------------------------------------------------------------------------------- /database/migrations/2017_03_20_191601_create_channels_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('name', 50)->unique(); 19 | $table->string('slug', 50)->unique(); 20 | $table->string('description')->nullable(); 21 | $table->boolean('archived')->default(false); 22 | $table->string('color', 7)->default('#000000'); 23 | $table->timestamps(); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('channels'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /resources/assets/js/components/Wysiwyg.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 17 | 18 | 39 | 40 | 45 | 46 | -------------------------------------------------------------------------------- /config/view.php: -------------------------------------------------------------------------------- 1 | [ 17 | resource_path('views'), 18 | ], 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Compiled View Path 23 | |-------------------------------------------------------------------------- 24 | | 25 | | This option determines where all the compiled Blade templates will be 26 | | stored for your application. Typically, this is within the storage 27 | | directory. However, as usual, you are free to change this value. 28 | | 29 | */ 30 | 31 | 'compiled' => realpath(storage_path('framework/views')), 32 | 33 | ]; 34 | -------------------------------------------------------------------------------- /resources/assets/js/components/Login.vue: -------------------------------------------------------------------------------- 1 | 39 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/ResetPasswordController.php: -------------------------------------------------------------------------------- 1 | middleware('guest'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/Http/Requests/CreatePostRequest.php: -------------------------------------------------------------------------------- 1 | 'required|spamfree' 44 | ]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /database/migrations/2017_05_19_141013_create_thread_subscriptions_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->unsignedInteger('user_id'); 19 | $table->unsignedInteger('thread_id'); 20 | $table->timestamps(); 21 | $table->unique(['user_id', 'thread_id']); 22 | 23 | $table->foreign('thread_id') 24 | ->references('id') 25 | ->on('threads') 26 | ->onDelete('cascade'); 27 | }); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | * 33 | * @return void 34 | */ 35 | public function down() 36 | { 37 | Schema::dropIfExists('thread_subscriptions'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/ThreadSubscription.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 25 | } 26 | 27 | /** 28 | * Get the thread associated with the subscription. 29 | * 30 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 31 | */ 32 | public function thread() 33 | { 34 | return $this->belongsTo(Thread::class); 35 | } 36 | 37 | /** 38 | * Notify the related user that the thread was updated. 39 | * 40 | * @param \App\Reply $reply 41 | */ 42 | public function notify($reply) 43 | { 44 | $this->user->notify(new ThreadWasUpdated($this->thread, $reply)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /public/images/little-guy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/images/avatars/default.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/Feature/LeaderboardTest.php: -------------------------------------------------------------------------------- 1 | each(function ($reputation) { 18 | create(User::class, [ 19 | 'reputation' => $reputation 20 | ]); 21 | }); 22 | 23 | $reputation = collect($this->getJson(route('api.leaderboard.index'))->json()['leaderboard']) 24 | ->pluck('reputation') 25 | ->toArray(); 26 | 27 | $this->assertEquals([100, 90, 80, 70, 60, 50, 40, 30, 20, 10], $reputation); 28 | } 29 | 30 | /** @test */ 31 | public function users_can_access_the_forum_leaderboard_page() 32 | { 33 | $this->get(route('leaderboard.index')) 34 | ->assertStatus(Response::HTTP_OK) 35 | ->assertViewIs('leaderboard.index') 36 | ->assertSee('Leaderboard'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'domain' => env('MAILGUN_DOMAIN'), 18 | 'secret' => env('MAILGUN_SECRET'), 19 | ], 20 | 21 | 'ses' => [ 22 | 'key' => env('SES_KEY'), 23 | 'secret' => env('SES_SECRET'), 24 | 'region' => 'us-east-1', 25 | ], 26 | 27 | 'sparkpost' => [ 28 | 'secret' => env('SPARKPOST_SECRET'), 29 | ], 30 | 31 | 'stripe' => [ 32 | 'model' => App\User::class, 33 | 'key' => env('STRIPE_KEY'), 34 | 'secret' => env('STRIPE_SECRET'), 35 | ], 36 | 37 | 'recaptcha' => [ 38 | 'key' => env('RECAPTCHA_KEY'), 39 | 'secret' => env('RECAPTCHA_SECRET') 40 | ] 41 | ]; 42 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('name')->nullable(); 19 | $table->string('username')->unique(); 20 | $table->string('email')->unique(); 21 | $table->string('password'); 22 | $table->unsignedInteger('reputation')->default(0); 23 | $table->string('avatar_path')->nullable(); 24 | $table->boolean('confirmed')->default(false); 25 | $table->string('confirmation_token', 25)->nullable()->unique(); 26 | $table->rememberToken(); 27 | $table->timestamps(); 28 | }); 29 | } 30 | 31 | /** 32 | * Reverse the migrations. 33 | * 34 | * @return void 35 | */ 36 | public function down() 37 | { 38 | Schema::dropIfExists('users'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/Filters/ThreadFilters.php: -------------------------------------------------------------------------------- 1 | firstOrFail(); 25 | 26 | return $this->builder->where('user_id', $user->id); 27 | } 28 | 29 | /** 30 | * Filter the query according to most popular threads. 31 | * 32 | * @return \Illuminate\Database\Eloquent\Builder 33 | */ 34 | protected function popular() 35 | { 36 | $this->builder->getQuery()->orders = []; 37 | 38 | return $this->builder->orderBy('replies_count', 'desc'); 39 | } 40 | 41 | /** 42 | * Filter the query according to those that are unanswered. 43 | * 44 | * @return \Illuminate\Database\Eloquent\Builder 45 | */ 46 | protected function unanswered() 47 | { 48 | return $this->builder->where('replies_count', 0); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/Notifications/ThreadWasUpdated.php: -------------------------------------------------------------------------------- 1 | thread = $thread; 32 | $this->reply = $reply; 33 | } 34 | 35 | /** 36 | * Get the notification's delivery channels. 37 | * 38 | * @return array 39 | */ 40 | public function via() 41 | { 42 | return ['database']; 43 | } 44 | 45 | /** 46 | * Get the array representation of the notification. 47 | * 48 | * @return array 49 | */ 50 | public function toArray() 51 | { 52 | return [ 53 | 'message' => $this->reply->owner->name.' replied to '.$this->thread->title, 54 | 'notifier' => $this->reply->owner, 55 | 'link' => $this->reply->path() 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Council [![Build Status](https://travis-ci.org/JeffreyWay/council.svg?branch=master)](https://travis-ci.org/JeffreyWay/council) 2 | 3 | This is an open source forum that was built and maintained at Laracasts.com. 4 | 5 | ## Installation 6 | 7 | ### Prerequisites 8 | 9 | * To run this project, you must have PHP 7 installed. 10 | * You should setup a host on your web server for your local domain. For this you could also configure Laravel Homestead or Valet. 11 | * If you want use Redis as your cache driver you need to install the Redis Server. You can either use homebrew on a Mac or compile from source (https://redis.io/topics/quickstart). 12 | 13 | ### Step 1 14 | 15 | Begin by cloning this repository to your machine, and installing all Composer & NPM dependencies. 16 | 17 | ```bash 18 | git clone git@github.com:JeffreyWay/council.git 19 | cd council && composer install && npm install 20 | php artisan council:install 21 | npm run dev 22 | ``` 23 | 24 | ### Step 2 25 | 26 | Next, boot up a server and visit your forum. If using a tool like Laravel Valet, of course the URL will default to `http://council.test`. 27 | 28 | 1. Visit: `http://council.test/register` to register a new forum account. 29 | 2. Edit `config/council.php`, and add any email address that should be marked as an administrator. 30 | 3. Visit: `http://council.test/admin/channels` to seed your forum with one or more channels. 31 | -------------------------------------------------------------------------------- /resources/views/layouts/base.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{ config('app.name', 'Laravel') }} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 26 | 27 | @yield('head') 28 | 29 | 30 | 31 |
32 | @include ('layouts.nav') 33 | 34 |
35 |
36 | @yield('content') 37 |
38 |
39 | 40 | 41 |
42 | 43 | 44 | 45 | @yield('scripts') 46 | 47 | 48 | -------------------------------------------------------------------------------- /tests/Feature/AddAvatarTest.php: -------------------------------------------------------------------------------- 1 | withExceptionHandling(); 18 | 19 | $this->json('POST', 'api/users/1/avatar') 20 | ->assertStatus(401); 21 | } 22 | 23 | /** @test */ 24 | public function a_valid_avatar_must_be_provided() 25 | { 26 | $this->withExceptionHandling()->signIn(); 27 | 28 | $this->json('POST', route('avatar', auth()->id()), [ 29 | 'avatar' => 'not-an-image' 30 | ])->assertStatus(422); 31 | } 32 | 33 | /** @test */ 34 | public function a_user_may_add_an_avatar_to_their_profile() 35 | { 36 | $this->signIn(); 37 | 38 | Storage::fake('public'); 39 | 40 | $this->json('POST', route('avatar', auth()->id()), [ 41 | 'avatar' => $file = UploadedFile::fake()->image('avatar.jpg') 42 | ]); 43 | 44 | $this->assertEquals(asset('avatars/'.$file->hashName()), auth()->user()->avatar_path); 45 | 46 | Storage::disk('public')->assertExists('avatars/' . $file->hashName()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/Rules/Recaptcha.php: -------------------------------------------------------------------------------- 1 | post(static::URL, [ 22 | 'secret' => config('services.recaptcha.secret'), 23 | 'response' => $value, 24 | 'remoteip' => request()->ip() 25 | ])->json()['success']; 26 | } 27 | 28 | /** 29 | * Get the validation error message. 30 | * 31 | * @return string 32 | */ 33 | public function message() 34 | { 35 | return 'The recaptcha verification failed. Try again.'; 36 | } 37 | 38 | /** 39 | * Determine if Recaptcha's keys are set to test mode. 40 | * 41 | * @return bool 42 | */ 43 | public static function isInTestMode() 44 | { 45 | return Zttp::asFormParams()->post(static::URL, [ 46 | 'secret' => config('services.recaptcha.secret'), 47 | 'response' => 'test', 48 | 'remoteip' => request()->ip() 49 | ])->json()['success']; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /resources/views/modals/login.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | 6 | 7 |
8 | 9 |
10 | 11 | 12 |
13 | 14 |
15 | 16 | or register 17 |
18 | 19 |
20 | 21 |
22 |
23 |
24 |
25 | -------------------------------------------------------------------------------- /database/migrations/2017_03_15_175959_create_threads_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('slug')->unique()->nullable(); 19 | $table->unsignedInteger('user_id'); 20 | $table->unsignedInteger('channel_id'); 21 | $table->unsignedInteger('replies_count')->default(0); 22 | $table->unsignedInteger('visits')->default(0); 23 | $table->string('title'); 24 | $table->text('body'); 25 | $table->unsignedInteger('best_reply_id')->nullable(); 26 | $table->boolean('locked')->default(false); 27 | $table->boolean('pinned')->default(false); 28 | $table->timestamps(); 29 | 30 | $table->foreign('best_reply_id') 31 | ->references('id') 32 | ->on('replies') 33 | ->onDelete('set null'); 34 | }); 35 | } 36 | 37 | /** 38 | * Reverse the migrations. 39 | * 40 | * @return void 41 | */ 42 | public function down() 43 | { 44 | Schema::dropIfExists('threads'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /resources/assets/sass/components/_btn.scss: -------------------------------------------------------------------------------- 1 | .btn { 2 | @apply .bg-grey .text-white .font-normal .text-sm .py-2 .px-6 .rounded .font-semibold; 3 | cursor: pointer; 4 | 5 | &.is-green { 6 | @apply .bg-green; 7 | box-shadow: 1px 2px 15px 0 rgba(16, 234, 155, 0.48); 8 | 9 | &:hover { 10 | @apply .bg-green-darker; 11 | } 12 | } 13 | 14 | &.is-outlined { 15 | background: transparent; 16 | @apply .border; 17 | } 18 | 19 | &:hover { 20 | @apply .bg-blue-dark .text-white; 21 | } 22 | 23 | &.is-narrow { 24 | @apply .py-1 .px-2; 25 | } 26 | } 27 | 28 | // Hat-tip to Bulma. 29 | .loader { 30 | color: transparent !important; 31 | pointer-events: none; 32 | position: relative; 33 | } 34 | 35 | .loader:after { 36 | animation: spinAround 500ms infinite linear; 37 | border: 2px solid #dbdbdb; 38 | border-radius: 290486px; 39 | border-right-color: transparent; 40 | border-top-color: transparent; 41 | content: ""; 42 | display: block; 43 | width: 1em; 44 | height: 1em; 45 | position: relative; 46 | position: absolute; 47 | left: calc(50% - (1em / 2)); 48 | top: calc(50% - (1em / 2)); 49 | position: absolute !important; 50 | } 51 | 52 | @keyframes spinAround { 53 | from { 54 | -webkit-transform: rotate(0deg); 55 | transform: rotate(0deg); 56 | } 57 | to { 58 | -webkit-transform: rotate(359deg); 59 | transform: rotate(359deg); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/Filters/Filters.php: -------------------------------------------------------------------------------- 1 | request = $request; 36 | } 37 | 38 | /** 39 | * Apply the filters. 40 | * 41 | * @param \Illuminate\Database\Eloquent\Builder $builder 42 | * @return \Illuminate\Database\Eloquent\Builder 43 | */ 44 | public function apply($builder) 45 | { 46 | $this->builder = $builder; 47 | 48 | foreach ($this->getFilters() as $filter => $value) { 49 | if (method_exists($this, $filter)) { 50 | $this->$filter($value); 51 | } 52 | } 53 | 54 | return $this->builder; 55 | } 56 | 57 | /** 58 | * Fetch all relevant filters from the request. 59 | * 60 | * @return array 61 | */ 62 | public function getFilters() 63 | { 64 | return array_filter($this->request->only($this->filters)); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /resources/views/breadcrumbs.blade.php: -------------------------------------------------------------------------------- 1 | 44 | -------------------------------------------------------------------------------- /resources/assets/js/components/ActivityFavorite.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 40 | -------------------------------------------------------------------------------- /resources/views/vendor/pagination/semantic-ui.blade.php: -------------------------------------------------------------------------------- 1 | @if ($paginator->hasPages()) 2 | 36 | @endif 37 | -------------------------------------------------------------------------------- /resources/assets/js/components/Paginator.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 5 | "watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 6 | "watch-poll": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --watch-poll --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 7 | "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", 8 | "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" 9 | }, 10 | "devDependencies": { 11 | "axios": "^0.17", 12 | "browser-sync": "^2.23.6", 13 | "browser-sync-webpack-plugin": "2.0.1", 14 | "cross-env": "^5.1", 15 | "jquery": "^3.3.1", 16 | "laravel-mix": "^2.1.11", 17 | "laravel-mix-tailwind": "^0.1.0", 18 | "lodash": "^4.17.5", 19 | "tailwindcss": "^0.4.3", 20 | "vue": "^2.5.13" 21 | }, 22 | "dependencies": { 23 | "at.js": "^1.5.3", 24 | "highlight.js": "^9.12.0", 25 | "jquery.caret": "^0.3.1", 26 | "moment": "^2.21.0", 27 | "trix": "^0.11.2", 28 | "vue-instantsearch": "^1.5.1", 29 | "vue-js-modal": "^1.3.12", 30 | "vue-template-compiler": "^2.5.13" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /resources/views/threads/reply.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |
6 | 7 | {{ $reply->owner->username }} 8 | said {{ $reply->created_at->diffForHumans() }}... 9 |
10 | 11 | @if (Auth::check()) 12 |
13 | 14 |
15 | @endif 16 |
17 |
18 | 19 |
20 |
21 |
22 | 23 |
24 | 25 | 26 | 27 |
28 | 29 |
30 |
31 | 32 | @can ('update', $reply) 33 | 37 | @endcan 38 |
39 |
40 | -------------------------------------------------------------------------------- /resources/assets/js/components/Leaderboard.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | -------------------------------------------------------------------------------- /tests/Unit/ActivityTest.php: -------------------------------------------------------------------------------- 1 | signIn(); 18 | 19 | $thread = create(\App\Thread::class); 20 | 21 | $this->assertDatabaseHas('activities', [ 22 | 'type' => 'created_thread', 23 | 'user_id' => auth()->id(), 24 | 'subject_id' => $thread->id, 25 | 'subject_type' => \App\Thread::class 26 | ]); 27 | 28 | $activity = Activity::first(); 29 | 30 | $this->assertEquals($activity->subject->id, $thread->id); 31 | } 32 | 33 | /** @test */ 34 | public function it_records_activity_when_a_reply_is_created() 35 | { 36 | $this->signIn(); 37 | 38 | $reply = create(\App\Reply::class); 39 | 40 | $this->assertEquals(2, Activity::count()); 41 | } 42 | 43 | /** @test */ 44 | public function it_fetches_a_feed_for_any_user() 45 | { 46 | $this->signIn(); 47 | 48 | create(\App\Thread::class, ['user_id' => auth()->id()], 3); 49 | 50 | auth()->user()->activity()->first()->update(['created_at' => Carbon::now()->subWeek()]); 51 | 52 | $feed = Activity::feed(auth()->user()); 53 | 54 | $this->assertCount(3, $feed->all()); 55 | $this->assertEquals([1, 1, 1], $feed->pluck('user_id')->toArray()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /public/css/vendor/jquery.atwho.css: -------------------------------------------------------------------------------- 1 | .atwho-view { 2 | position:absolute; 3 | top: 0; 4 | left: 0; 5 | display: none; 6 | margin-top: 18px; 7 | background: white; 8 | color: black; 9 | border: 1px solid #DDD; 10 | border-radius: 3px; 11 | box-shadow: 0 0 5px rgba(0,0,0,0.1); 12 | min-width: 120px; 13 | z-index: 11110 !important; 14 | } 15 | 16 | .atwho-view .atwho-header { 17 | padding: 5px; 18 | margin: 5px; 19 | cursor: pointer; 20 | border-bottom: solid 1px #eaeff1; 21 | color: #6f8092; 22 | font-size: 11px; 23 | font-weight: bold; 24 | } 25 | 26 | .atwho-view .atwho-header .small { 27 | color: #6f8092; 28 | float: right; 29 | padding-top: 2px; 30 | margin-right: -5px; 31 | font-size: 12px; 32 | font-weight: normal; 33 | } 34 | 35 | .atwho-view .atwho-header:hover { 36 | cursor: default; 37 | } 38 | 39 | .atwho-view .cur { 40 | background: #3366FF; 41 | color: white; 42 | } 43 | .atwho-view .cur small { 44 | color: white; 45 | } 46 | .atwho-view strong { 47 | color: #3366FF; 48 | } 49 | .atwho-view .cur strong { 50 | color: white; 51 | font:bold; 52 | } 53 | .atwho-view ul { 54 | /* width: 100px; */ 55 | list-style:none; 56 | padding:0; 57 | margin:auto; 58 | max-height: 200px; 59 | overflow-y: auto; 60 | } 61 | .atwho-view ul li { 62 | display: block; 63 | padding: 5px 10px; 64 | border-bottom: 1px solid #DDD; 65 | cursor: pointer; 66 | /* border-top: 1px solid #C8C8C8; */ 67 | } 68 | .atwho-view small { 69 | font-size: smaller; 70 | color: #777; 71 | font-weight: normal; 72 | } 73 | -------------------------------------------------------------------------------- /database/seeds/UsersSeeder.php: -------------------------------------------------------------------------------- 1 | 'John Doe', 20 | 'username' => 'johndoe', 21 | 'email' => 'john@example.com', 22 | 'password' => bcrypt('password') 23 | ], 24 | [ 25 | 'name' => 'Indiana Jones', 26 | 'username' => 'rotla1981', 27 | 'email' => 'indy@example.com', 28 | 'password' => bcrypt('password') 29 | ], 30 | [ 31 | 'name' => 'Ben Solo', 32 | 'username' => 'KyloRen', 33 | 'email' => 'kylo@example.com', 34 | 'password' => bcrypt('password') 35 | ], 36 | [ 37 | 'name' => 'Marty McFly', 38 | 'username' => '121gigawatts', 39 | 'email' => 'calvin@example.com', 40 | 'password' => bcrypt('password') 41 | ], 42 | ])->each(function ($user) { 43 | factory(User::class)->create( 44 | [ 45 | 'name' => $user['name'], 46 | 'username' => $user['username'], 47 | 'email' => $user['email'], 48 | 'password' => bcrypt('password') 49 | ] 50 | ); 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/LoginController.php: -------------------------------------------------------------------------------- 1 | middleware('guest', ['except' => 'logout']); 39 | } 40 | 41 | /** 42 | * The user has been authenticated. 43 | * 44 | * @param \Illuminate\Http\Request $request 45 | * @param mixed $user 46 | * @return mixed 47 | */ 48 | protected function authenticated(Request $request, $user) 49 | { 50 | if ($request->wantsJson()) { 51 | return response()->json(['redirect' => $this->redirectTo], 200); 52 | } 53 | 54 | redirect()->intended($this->redirectPath()); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/Activity.php: -------------------------------------------------------------------------------- 1 | morphTo(); 31 | } 32 | 33 | /** 34 | * Fetch the model record for the subject of the favorite. 35 | */ 36 | public function getFavoritedModelAttribute() 37 | { 38 | $favoritedModel = null; 39 | 40 | if ($this->subject_type === Favorite::class) { 41 | $subject = $this->subject()->firstOrFail(); 42 | 43 | if ($subject->favorited_type == Reply::class) { 44 | $favoritedModel = Reply::find($subject->favorited_id); 45 | } 46 | } 47 | 48 | return $favoritedModel; 49 | } 50 | 51 | /** 52 | * Fetch an activity feed for the given user. 53 | * 54 | * @param User $user 55 | * @return \Illuminate\Database\Eloquent\Collection; 56 | */ 57 | public static function feed($user) 58 | { 59 | return static::where('user_id', $user->id) 60 | ->latest() 61 | ->with('subject') 62 | ->paginate(30); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /resources/views/vendor/pagination/bootstrap-4.blade.php: -------------------------------------------------------------------------------- 1 | @if ($paginator->hasPages()) 2 | 36 | @endif 37 | -------------------------------------------------------------------------------- /resources/views/admin/channels/_form.blade.php: -------------------------------------------------------------------------------- 1 | {{ csrf_field() }} 2 |
3 | 4 | 5 |
6 | 7 |
8 | 9 | 10 |
11 | 12 |
13 | 14 | 15 |
16 | 17 |
18 | 19 | 20 | 24 |
25 | 26 |
27 | 28 |
29 | 30 | @if (count($errors)) 31 | 36 | @endif 37 | -------------------------------------------------------------------------------- /resources/assets/js/components/AvatarForm.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 61 | -------------------------------------------------------------------------------- /resources/assets/js/components/Replies.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 61 | -------------------------------------------------------------------------------- /tests/Feature/UpdateThreadsTest.php: -------------------------------------------------------------------------------- 1 | withExceptionHandling(); 17 | 18 | $this->signIn(); 19 | } 20 | 21 | /** @test */ 22 | function unauthorized_users_may_not_update_threads() 23 | { 24 | $thread = create(\App\Thread::class, ['user_id' => create(\App\User::class)->id]); 25 | 26 | $this->patch($thread->path(), [])->assertStatus(403); 27 | } 28 | 29 | /** @test */ 30 | function a_thread_requires_a_title_and_body_to_be_updated() 31 | { 32 | $thread = create(\App\Thread::class, ['user_id' => auth()->id()]); 33 | 34 | $this->patch($thread->path(), [ 35 | 'title' => 'Changed' 36 | ])->assertSessionHasErrors('body'); 37 | 38 | $this->patch($thread->path(), [ 39 | 'body' => 'Changed' 40 | ])->assertSessionHasErrors('title'); 41 | } 42 | 43 | /** @test */ 44 | function a_thread_can_be_updated_by_its_creator() 45 | { 46 | $thread = create(\App\Thread::class, ['user_id' => auth()->id()]); 47 | 48 | $this->patch($thread->path(), [ 49 | 'title' => 'Changed', 50 | 'body' => 'Changed body.' 51 | ]); 52 | 53 | tap($thread->fresh(), function ($thread) { 54 | $this->assertEquals('Changed', $thread->title); 55 | $this->assertEquals('Changed body.', $thread->body); 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests/Console 15 | 16 | 17 | 18 | ./tests/Feature 19 | 20 | 21 | 22 | ./tests/Unit 23 | 24 | 25 | 26 | 27 | ./app 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /tests/Feature/FavoritesTest.php: -------------------------------------------------------------------------------- 1 | withExceptionHandling() 16 | ->post('replies/1/favorites') 17 | ->assertRedirect('/login'); 18 | } 19 | 20 | /** @test */ 21 | public function an_authenticated_user_can_favorite_any_reply() 22 | { 23 | $this->signIn(); 24 | 25 | $reply = create(\App\Reply::class); 26 | 27 | $this->post(route('replies.favorite', $reply->id)); 28 | 29 | $this->assertCount(1, $reply->favorites); 30 | } 31 | 32 | /** @test */ 33 | public function an_authenticated_user_can_unfavorite_a_reply() 34 | { 35 | $this->signIn(); 36 | 37 | $reply = create(\App\Reply::class); 38 | 39 | $reply->favorite(); 40 | 41 | $this->delete(route('replies.unfavorite', $reply->id)); 42 | 43 | $this->assertCount(0, $reply->favorites); 44 | } 45 | 46 | /** @test */ 47 | function an_authenticated_user_may_only_favorite_a_reply_once() 48 | { 49 | $this->signIn(); 50 | 51 | $reply = create(\App\Reply::class); 52 | 53 | try { 54 | $this->post(route('replies.favorite', $reply->id)); 55 | $this->post(route('replies.favorite', $reply->id)); 56 | } catch (\Exception $e) { 57 | $this->fail('Did not expect to insert the same record set twice.'); 58 | } 59 | 60 | $this->assertCount(1, $reply->favorites); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/Notifications/YouWereMentioned.php: -------------------------------------------------------------------------------- 1 | subject = $subject; 26 | } 27 | 28 | /** 29 | * Get the notification's delivery channels. 30 | * 31 | * @param mixed $notifiable 32 | * @return array 33 | */ 34 | public function via($notifiable) 35 | { 36 | return ['database']; 37 | } 38 | 39 | /** 40 | * Get the array representation of the notification. 41 | * 42 | * @param mixed $notifiable 43 | * @return array 44 | */ 45 | public function toArray($notifiable) 46 | { 47 | return [ 48 | 'message' => $this->message(), 49 | 'notifier' => $this->user(), 50 | 'link' => $this->subject->path() 51 | ]; 52 | } 53 | 54 | /** 55 | * Get a message title for the notification. 56 | */ 57 | public function message() 58 | { 59 | return sprintf('%s mentioned you in "%s"', $this->user()->username, $this->subject->title()); 60 | } 61 | 62 | /** 63 | * Get the associated user for the subject. 64 | */ 65 | public function user() 66 | { 67 | return $this->subject instanceof Reply ? $this->subject->owner : $this->subject->creator; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /resources/assets/js/components/Flash.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 64 | -------------------------------------------------------------------------------- /app/Trending.php: -------------------------------------------------------------------------------- 1 | cacheKey(), collect()) 17 | ->sortByDesc('score') 18 | ->slice(0, 5) 19 | ->values(); 20 | } 21 | 22 | /** 23 | * Push a new thread to the trending list. 24 | * 25 | * @param Thread $thread 26 | */ 27 | public function push($thread, $increment = 1) 28 | { 29 | $trending = Cache::get($this->cacheKey(), collect()); 30 | 31 | $trending[$thread->id] = (object) [ 32 | 'score' => $this->score($thread) + $increment, 33 | 'title' => $thread->title, 34 | 'path' => $thread->path(), 35 | ]; 36 | 37 | Cache::forever($this->cacheKey(), $trending); 38 | } 39 | 40 | /** 41 | * Get the trending score of the given thread. 42 | * 43 | * @param int 44 | */ 45 | public function score($thread) 46 | { 47 | $trending = Cache::get($this->cacheKey(), collect()); 48 | 49 | if (! isset($trending[$thread->id])) { 50 | return 0; 51 | } 52 | 53 | return $trending[$thread->id]->score; 54 | } 55 | 56 | /** 57 | * Reset all trending threads. 58 | */ 59 | public function reset() 60 | { 61 | return Cache::forget($this->cacheKey()); 62 | } 63 | 64 | /** 65 | * Get the cache key name. 66 | * 67 | * @return string 68 | */ 69 | private function cacheKey() 70 | { 71 | return 'trending_threads'; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /resources/assets/js/components/Favorite.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 52 | -------------------------------------------------------------------------------- /resources/assets/js/components/Activities.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 61 | -------------------------------------------------------------------------------- /resources/views/threads/search.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('content') 4 | 10 | @include('breadcrumbs') 11 | 12 |
13 |
14 |
15 |

Search

16 | 17 | 18 | 19 | 20 |
21 | 22 |
23 |

24 | Filter By Channel 25 |

26 | 27 |
28 | 29 |
30 |
31 |
32 | 33 |
34 | 35 | 42 | 43 |
44 |
45 |
46 | @endsection 47 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | singleton( 30 | Illuminate\Contracts\Http\Kernel::class, 31 | App\Http\Kernel::class 32 | ); 33 | 34 | $app->singleton( 35 | Illuminate\Contracts\Console\Kernel::class, 36 | App\Console\Kernel::class 37 | ); 38 | 39 | $app->singleton( 40 | Illuminate\Contracts\Debug\ExceptionHandler::class, 41 | App\Exceptions\Handler::class 42 | ); 43 | 44 | /* 45 | |-------------------------------------------------------------------------- 46 | | Return The Application 47 | |-------------------------------------------------------------------------- 48 | | 49 | | This script returns the application instance. The instance is given to 50 | | the calling script so we can separate the building of the instances 51 | | from the actual running of the application and sending responses. 52 | | 53 | */ 54 | 55 | return $app; 56 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | disableExceptionHandling(); 21 | } 22 | 23 | protected function signIn($user = null) 24 | { 25 | $user = $user ?: create(\App\User::class); 26 | 27 | $this->actingAs($user); 28 | 29 | return $this; 30 | } 31 | 32 | protected function signInAdmin($admin = null) 33 | { 34 | $admin = $admin ?: create(\App\User::class); 35 | 36 | config(['council.administrators' => [$admin->email]]); 37 | 38 | $this->actingAs($admin); 39 | 40 | return $this; 41 | } 42 | 43 | // Hat tip, @adamwathan. 44 | protected function disableExceptionHandling() 45 | { 46 | $this->oldExceptionHandler = $this->app->make(ExceptionHandler::class); 47 | 48 | $this->app->instance(ExceptionHandler::class, new class extends Handler { 49 | public function __construct() 50 | { 51 | } 52 | public function report(\Exception $e) 53 | { 54 | } 55 | public function render($request, \Exception $e) 56 | { 57 | throw $e; 58 | } 59 | }); 60 | } 61 | 62 | protected function withExceptionHandling() 63 | { 64 | $this->app->instance(ExceptionHandler::class, $this->oldExceptionHandler); 65 | 66 | return $this; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/RecordsActivity.php: -------------------------------------------------------------------------------- 1 | guest()) { 13 | return; 14 | } 15 | 16 | foreach (static::getActivitiesToRecord() as $event) { 17 | static::$event(function ($model) use ($event) { 18 | $model->recordActivity($event); 19 | }); 20 | } 21 | 22 | static::deleting(function ($model) { 23 | $model->activity()->delete(); 24 | }); 25 | } 26 | 27 | /** 28 | * Fetch all model events that require activity recording. 29 | * 30 | * @return array 31 | */ 32 | protected static function getActivitiesToRecord() 33 | { 34 | return ['created']; 35 | } 36 | 37 | /** 38 | * Record new activity for the model. 39 | * 40 | * @param string $event 41 | */ 42 | protected function recordActivity($event) 43 | { 44 | $this->activity()->create([ 45 | 'user_id' => auth()->id(), 46 | 'type' => $this->getActivityType($event) 47 | ]); 48 | } 49 | 50 | /** 51 | * Fetch the activity relationship. 52 | * 53 | * @return \Illuminate\Database\Eloquent\Relations\MorphMany 54 | */ 55 | public function activity() 56 | { 57 | return $this->morphMany(\App\Activity::class, 'subject'); 58 | } 59 | 60 | /** 61 | * Determine the activity type. 62 | * 63 | * @param string $event 64 | * @return string 65 | */ 66 | protected function getActivityType($event) 67 | { 68 | $type = strtolower((new \ReflectionClass($this))->getShortName()); 69 | 70 | return "{$event}_{$type}"; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/Feature/BestReplyTest.php: -------------------------------------------------------------------------------- 1 | signIn(); 16 | 17 | $thread = create(\App\Thread::class, ['user_id' => auth()->id()]); 18 | 19 | $replies = create(\App\Reply::class, ['thread_id' => $thread->id], 2); 20 | 21 | $this->assertFalse($replies[1]->isBest()); 22 | 23 | $this->postJson(route('best-replies.store', [$replies[1]->id])); 24 | 25 | $this->assertTrue($replies[1]->fresh()->isBest()); 26 | } 27 | 28 | /** @test */ 29 | public function only_the_thread_creator_may_mark_a_reply_as_best() 30 | { 31 | $this->withExceptionHandling(); 32 | 33 | $this->signIn(); 34 | 35 | $thread = create(\App\Thread::class, ['user_id' => auth()->id()]); 36 | 37 | $replies = create(\App\Reply::class, ['thread_id' => $thread->id], 2); 38 | 39 | $this->signIn(create(\App\User::class)); 40 | 41 | $this->postJson(route('best-replies.store', [$replies[1]->id]))->assertStatus(403); 42 | 43 | $this->assertFalse($replies[1]->fresh()->isBest()); 44 | } 45 | 46 | /** @test */ 47 | public function if_a_best_reply_is_deleted_then_the_thread_is_properly_updated_to_reflect_that() 48 | { 49 | $this->signIn(); 50 | 51 | $reply = create(\App\Reply::class, ['user_id' => auth()->id()]); 52 | 53 | $reply->thread->markBestReply($reply); 54 | 55 | $this->deleteJson(route('replies.destroy', $reply)); 56 | 57 | $this->assertNull($reply->thread->fresh()->best_reply_id); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | make(Illuminate\Contracts\Console\Kernel::class); 32 | 33 | $status = $kernel->handle( 34 | $input = new Symfony\Component\Console\Input\ArgvInput, 35 | new Symfony\Component\Console\Output\ConsoleOutput 36 | ); 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Shutdown The Application 41 | |-------------------------------------------------------------------------- 42 | | 43 | | Once Artisan has finished running. We will fire off the shutdown events 44 | | so that any final work may be done by the application before we shut 45 | | down the process. This is the last thing to happen to the request. 46 | | 47 | */ 48 | 49 | $kernel->terminate($input, $status); 50 | 51 | exit($status); 52 | -------------------------------------------------------------------------------- /config/broadcasting.php: -------------------------------------------------------------------------------- 1 | env('BROADCAST_DRIVER', 'null'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Broadcast Connections 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may define all of the broadcast connections that will be used 26 | | to broadcast events to other systems or over websockets. Samples of 27 | | each available type of connection are provided inside this array. 28 | | 29 | */ 30 | 31 | 'connections' => [ 32 | 33 | 'pusher' => [ 34 | 'driver' => 'pusher', 35 | 'key' => env('PUSHER_APP_KEY'), 36 | 'secret' => env('PUSHER_APP_SECRET'), 37 | 'app_id' => env('PUSHER_APP_ID'), 38 | 'options' => [ 39 | 'cluster' => env('PUSHER_APP_CLUSTER'), 40 | 'encrypted' => true, 41 | ], 42 | ], 43 | 44 | 'redis' => [ 45 | 'driver' => 'redis', 46 | 'connection' => 'default', 47 | ], 48 | 49 | 'log' => [ 50 | 'driver' => 'log', 51 | ], 52 | 53 | 'null' => [ 54 | 'driver' => 'null', 55 | ], 56 | 57 | ], 58 | 59 | ]; 60 | -------------------------------------------------------------------------------- /resources/views/vendor/pagination/default.blade.php: -------------------------------------------------------------------------------- 1 | @if ($paginator->hasPages()) 2 |
3 | 37 |
38 | @endif 39 | -------------------------------------------------------------------------------- /resources/views/layouts/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{ config('app.name', 'Laravel') }} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 26 | 27 | 28 | 29 | @yield('head') 30 | 31 | 32 | 33 |
34 | @include ('layouts.nav') 35 | 36 |
37 |
38 | @section('sidebar') 39 | @include('sidebar') 40 | @show 41 | 42 |
43 | @yield('content') 44 |
45 | 46 | @include('channels-sidebar') 47 |
48 |
49 | 50 | 51 | 52 |
53 | @include('modals.all') 54 |
55 |
56 | 57 | 58 | 59 | @yield('scripts') 60 | 61 | 62 | -------------------------------------------------------------------------------- /tests/Feature/LockThreadsTest.php: -------------------------------------------------------------------------------- 1 | withExceptionHandling(); 17 | 18 | $this->signIn(); 19 | 20 | $thread = create(\App\Thread::class, ['user_id' => auth()->id()]); 21 | 22 | $this->post(route('locked-threads.store', $thread))->assertStatus(403); 23 | 24 | $this->assertFalse($thread->fresh()->locked); 25 | } 26 | 27 | /** @test */ 28 | function administrators_can_lock_threads() 29 | { 30 | $this->signInAdmin(); 31 | 32 | $thread = create(\App\Thread::class, ['user_id' => auth()->id()]); 33 | 34 | $this->post(route('locked-threads.store', $thread)); 35 | 36 | $this->assertTrue($thread->fresh()->locked, 'Failed asserting that the thread was locked.'); 37 | } 38 | 39 | /** @test */ 40 | function administrators_can_unlock_threads() 41 | { 42 | $this->signInAdmin(); 43 | 44 | $thread = create(\App\Thread::class, ['user_id' => auth()->id(), 'locked' => true]); 45 | 46 | $this->delete(route('locked-threads.destroy', $thread)); 47 | 48 | $this->assertFalse($thread->fresh()->locked, 'Failed asserting that the thread was unlocked.'); 49 | } 50 | 51 | /** @test */ 52 | public function once_locked_a_thread_may_not_receive_new_replies() 53 | { 54 | $this->signIn(); 55 | 56 | $thread = create(\App\Thread::class, ['locked' => true]); 57 | 58 | $this->post($thread->path() . '/replies', [ 59 | 'body' => 'Foobar', 60 | 'user_id' => auth()->id() 61 | ])->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/Channel.php: -------------------------------------------------------------------------------- 1 | 'boolean' 21 | ]; 22 | 23 | /** 24 | * Boot the channels model. 25 | */ 26 | protected static function boot() 27 | { 28 | parent::boot(); 29 | 30 | static::addGlobalScope('active', function ($builder) { 31 | $builder->where('archived', false); 32 | }); 33 | 34 | static::addGlobalScope('sorted', function ($builder) { 35 | $builder->orderBy('name', 'asc'); 36 | }); 37 | } 38 | 39 | /** 40 | * Get the route key name for Laravel. 41 | * 42 | * @return string 43 | */ 44 | public function getRouteKeyName() 45 | { 46 | return 'slug'; 47 | } 48 | 49 | /** 50 | * A channel consists of threads. 51 | * 52 | * @return \Illuminate\Database\Eloquent\Relations\HasMany 53 | */ 54 | public function threads() 55 | { 56 | return $this->hasMany(Thread::class); 57 | } 58 | 59 | /** 60 | * Archive the channel. 61 | */ 62 | public function archive() 63 | { 64 | $this->update(['archived' => true]); 65 | } 66 | 67 | /** 68 | * Set the name of the channel. 69 | * 70 | * @param string $name 71 | */ 72 | public function setNameAttribute($name) 73 | { 74 | $this->attributes['name'] = $name; 75 | $this->attributes['slug'] = str_slug($name); 76 | } 77 | 78 | /** 79 | * Get a new query builder that includes archives. 80 | */ 81 | public static function withArchived() 82 | { 83 | return (new static)->newQueryWithoutScope('active'); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | /* 10 | |-------------------------------------------------------------------------- 11 | | Register The Auto Loader 12 | |-------------------------------------------------------------------------- 13 | | 14 | | Composer provides a convenient, automatically generated class loader for 15 | | our application. We just need to utilize it! We'll simply require it 16 | | into the script here so that we don't have to worry about manual 17 | | loading any of our classes later on. It feels great to relax. 18 | | 19 | */ 20 | 21 | require __DIR__.'/../bootstrap/autoload.php'; 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | Turn On The Lights 26 | |-------------------------------------------------------------------------- 27 | | 28 | | We need to illuminate PHP development, so let us turn on the lights. 29 | | This bootstraps the framework and gets it ready for use, then it 30 | | will load up this application so that we can run it and send 31 | | the responses back to the browser and delight our users. 32 | | 33 | */ 34 | 35 | $app = require_once __DIR__.'/../bootstrap/app.php'; 36 | 37 | /* 38 | |-------------------------------------------------------------------------- 39 | | Run The Application 40 | |-------------------------------------------------------------------------- 41 | | 42 | | Once we have the application, we can handle the incoming request 43 | | through the kernel, and send the associated response back to 44 | | the client's browser allowing them to enjoy the creative 45 | | and wonderful application we have prepared for them. 46 | | 47 | */ 48 | 49 | $kernel = $app->make(Illuminate\Contracts\Http\Kernel::class); 50 | 51 | $response = $kernel->handle( 52 | $request = Illuminate\Http\Request::capture() 53 | ); 54 | 55 | $response->send(); 56 | 57 | $kernel->terminate($request, $response); 58 | -------------------------------------------------------------------------------- /resources/views/admin/channels/index.blade.php: -------------------------------------------------------------------------------- 1 | @extends('admin.layout.app') 2 | 3 | @section('administration-content') 4 |

5 | 6 | New Channel 7 | 8 |

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | @forelse($channels as $channel) 23 | 24 | 27 | 28 | 29 | 30 | 31 | 34 | 35 | @empty 36 | 37 | 38 | 39 | @endforelse 40 | 41 |
NameSlugDescriptionThreadsActions
25 | 26 | {{ $channel->name }}{{ $channel->slug }}{{ $channel->description }}{{ $channel->threads_count }} 32 | Edit 33 |
Nothing here.
42 | @endsection 43 | -------------------------------------------------------------------------------- /tests/Feature/NotificationsTest.php: -------------------------------------------------------------------------------- 1 | signIn(); 18 | } 19 | 20 | /** @test */ 21 | function a_notification_is_prepared_when_a_subscribed_thread_receives_a_new_reply_that_is_not_by_the_current_user() 22 | { 23 | $thread = create(\App\Thread::class)->subscribe(); 24 | 25 | $this->assertCount(0, auth()->user()->notifications); 26 | 27 | $thread->addReply([ 28 | 'user_id' => auth()->id(), 29 | 'body' => 'Some reply here' 30 | ]); 31 | 32 | $this->assertCount(0, auth()->user()->fresh()->notifications); 33 | 34 | $thread->addReply([ 35 | 'user_id' => create(\App\User::class)->id, 36 | 'body' => 'Some reply here' 37 | ]); 38 | 39 | $this->assertCount(1, auth()->user()->fresh()->notifications); 40 | } 41 | 42 | /** @test */ 43 | function a_user_can_fetch_their_unread_notifications() 44 | { 45 | create(DatabaseNotification::class); 46 | 47 | $this->assertCount( 48 | 1, 49 | $this->getJson(route('user-notifications', auth()->user()->name))->json() 50 | ); 51 | } 52 | 53 | /** @test */ 54 | function a_user_can_mark_a_notification_as_read() 55 | { 56 | create(DatabaseNotification::class); 57 | 58 | tap(auth()->user(), function ($user) { 59 | $this->assertCount(1, $user->unreadNotifications); 60 | 61 | $this->delete(route('user-notification.destroy', [$user->name, $user->unreadNotifications->first()->id])); 62 | 63 | $this->assertCount(0, $user->fresh()->unreadNotifications); 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/Providers/RouteServiceProvider.php: -------------------------------------------------------------------------------- 1 | where('slug', $slug)->firstOrFail(); 30 | }); 31 | } 32 | 33 | /** 34 | * Define the routes for the application. 35 | * 36 | * @return void 37 | */ 38 | public function map() 39 | { 40 | $this->mapApiRoutes(); 41 | 42 | $this->mapWebRoutes(); 43 | 44 | // 45 | } 46 | 47 | /** 48 | * Define the "web" routes for the application. 49 | * 50 | * These routes all receive session state, CSRF protection, etc. 51 | * 52 | * @return void 53 | */ 54 | protected function mapWebRoutes() 55 | { 56 | Route::middleware('web') 57 | ->namespace($this->namespace) 58 | ->group(base_path('routes/web.php')); 59 | } 60 | 61 | /** 62 | * Define the "api" routes for the application. 63 | * 64 | * These routes are typically stateless. 65 | * 66 | * @return void 67 | */ 68 | protected function mapApiRoutes() 69 | { 70 | Route::prefix('api') 71 | ->middleware('api') 72 | ->namespace($this->namespace) 73 | ->group(base_path('routes/api.php')); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/Favoritable.php: -------------------------------------------------------------------------------- 1 | favorites->each->delete(); 16 | }); 17 | } 18 | 19 | /** 20 | * A reply can be favorited. 21 | * 22 | * @return \Illuminate\Database\Eloquent\Relations\MorphMany 23 | */ 24 | public function favorites() 25 | { 26 | return $this->morphMany(Favorite::class, 'favorited'); 27 | } 28 | 29 | /** 30 | * Favorite the current reply. 31 | * 32 | * @return Model 33 | */ 34 | public function favorite() 35 | { 36 | $attributes = ['user_id' => auth()->id()]; 37 | 38 | if (! $this->favorites()->where($attributes)->exists()) { 39 | return $this->favorites()->create($attributes); 40 | } 41 | } 42 | 43 | /** 44 | * Unfavorite the current reply. 45 | */ 46 | public function unfavorite() 47 | { 48 | $attributes = ['user_id' => auth()->id()]; 49 | 50 | $this->favorites()->where($attributes)->get()->each->delete(); 51 | } 52 | 53 | /** 54 | * Determine if the current reply has been favorited. 55 | * 56 | * @return bool 57 | */ 58 | public function isFavorited() 59 | { 60 | return (bool) $this->favorites->where('user_id', auth()->id())->count(); 61 | } 62 | 63 | /** 64 | * Fetch the favorited status as a property. 65 | * 66 | * @return bool 67 | */ 68 | public function getIsFavoritedAttribute() 69 | { 70 | return $this->isFavorited(); 71 | } 72 | 73 | /** 74 | * Get the number of favorites for the reply. 75 | * 76 | * @return int 77 | */ 78 | public function getFavoritesCountAttribute() 79 | { 80 | return $this->favorites->count(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel/laravel", 3 | "description": "Open source forum that was built and maintained at Laracasts.com.", 4 | "keywords": [ 5 | "framework", 6 | "laravel" 7 | ], 8 | "license": "MIT", 9 | "type": "project", 10 | "require": { 11 | "php": ">=7.1.3", 12 | "algolia/algoliasearch-client-php": "^1.23", 13 | "kitetail/zttp": "^0.3.0", 14 | "laravel/framework": "5.6.*", 15 | "laravel/scout": "~4.0", 16 | "laravel/tinker": "~1.0", 17 | "predis/predis": "^1.1", 18 | "stevebauman/purify": "^2.0", 19 | "fideloper/proxy": "~4.0" 20 | }, 21 | "require-dev": { 22 | "barryvdh/laravel-debugbar": "~3.1", 23 | "fzaninotto/faker": "~1.4", 24 | "mockery/mockery": "~1.0", 25 | "phpunit/phpunit": "~7.0", 26 | "nunomaduro/collision": "~1.1" 27 | }, 28 | "autoload": { 29 | "classmap": [ 30 | "database" 31 | ], 32 | "psr-4": { 33 | "App\\": "app/" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Tests\\": "tests/" 39 | }, 40 | "files": [ 41 | "tests/utilities/functions.php" 42 | ] 43 | }, 44 | "extra": { 45 | "laravel": { 46 | "dont-discover": [] 47 | } 48 | }, 49 | "scripts": { 50 | "post-root-package-install": [ 51 | "php -r \"file_exists('.env') || copy('.env.example', '.env');\"" 52 | ], 53 | "post-create-project-cmd": [ 54 | "php artisan key:generate" 55 | ], 56 | "post-autoload-dump": [ 57 | "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", 58 | "@php artisan package:discover" 59 | ] 60 | }, 61 | "config": { 62 | "preferred-install": "dist", 63 | "sort-packages": true, 64 | "optimize-autoloader": true 65 | }, 66 | "minimum-stability": "dev", 67 | "prefer-stable": true 68 | } -------------------------------------------------------------------------------- /resources/views/auth/passwords/email.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.app') 2 | 3 | @section('content') 4 |
5 |
6 |
7 |
8 |
Reset Password
9 |
10 | @if (session('status')) 11 |
12 | {{ session('status') }} 13 |
14 | @endif 15 | 16 |
17 | {{ csrf_field() }} 18 | 19 |
20 | 21 | 22 |
23 | 24 | 25 | @if ($errors->has('email')) 26 | 27 | {{ $errors->first('email') }} 28 | 29 | @endif 30 |
31 |
32 | 33 |
34 |
35 | 38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | @endsection 47 | -------------------------------------------------------------------------------- /tests/Unit/ChannelTest.php: -------------------------------------------------------------------------------- 1 | zip($items)->each(function ($pair) { 23 | [$actual, $expected] = $pair; 24 | 25 | Assert::assertTrue($actual->is($expected)); 26 | }); 27 | }); 28 | } 29 | 30 | /** @test */ 31 | public function a_channel_consists_of_threads() 32 | { 33 | $channel = create(\App\Channel::class); 34 | $thread = create(\App\Thread::class, ['channel_id' => $channel->id]); 35 | 36 | $this->assertTrue($channel->threads->contains($thread)); 37 | } 38 | 39 | /** @test */ 40 | public function a_channel_can_be_archived() 41 | { 42 | $channel = create(\App\Channel::class); 43 | 44 | $this->assertFalse($channel->archived); 45 | 46 | $channel->archive(); 47 | 48 | $this->assertTrue($channel->archived); 49 | } 50 | 51 | /** @test */ 52 | public function archived_channels_are_excluded_by_default() 53 | { 54 | create(\App\Channel::class); 55 | create(\App\Channel::class, ['archived' => true]); 56 | 57 | $this->assertEquals(1, Channel::count()); 58 | } 59 | 60 | /** @test */ 61 | public function channels_are_sorted_alphabetically_by_default() 62 | { 63 | $php = create(\App\Channel::class, ['name' => 'PHP']); 64 | $basic = create(\App\Channel::class, ['name' => 'Basic']); 65 | $zsh = create(\App\Channel::class, ['name' => 'Zsh']); 66 | 67 | Channel::all()->assertEquals([$basic, $php, $zsh]); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/Http/Controllers/RepliesController.php: -------------------------------------------------------------------------------- 1 | middleware('auth', ['except' => 'index']); 17 | } 18 | 19 | /** 20 | * Fetch all relevant replies. 21 | * 22 | * @param int $channelId 23 | * @param Thread $thread 24 | */ 25 | public function index($channelId, Thread $thread) 26 | { 27 | return $thread->replies()->paginate(config('council.pagination.perPage')); 28 | } 29 | 30 | /** 31 | * Persist a new reply. 32 | * 33 | * @param int $channelId 34 | * @param Thread $thread 35 | * @param CreatePostRequest $form 36 | * @return \Illuminate\Database\Eloquent\Model 37 | */ 38 | public function store($channelId, Thread $thread, CreatePostRequest $form) 39 | { 40 | if ($thread->locked) { 41 | return response('Thread is locked', 422); 42 | } 43 | 44 | return $thread->addReply([ 45 | 'body' => request('body'), 46 | 'user_id' => auth()->id() 47 | ])->load('owner'); 48 | } 49 | 50 | /** 51 | * Update an existing reply. 52 | * 53 | * @param Reply $reply 54 | */ 55 | public function update(Reply $reply) 56 | { 57 | $this->authorize('update', $reply); 58 | 59 | $reply->update(request()->validate(['body' => 'required|spamfree'])); 60 | } 61 | 62 | /** 63 | * Delete the given reply. 64 | * 65 | * @param Reply $reply 66 | * @return \Illuminate\Http\RedirectResponse 67 | */ 68 | public function destroy(Reply $reply) 69 | { 70 | $this->authorize('update', $reply); 71 | 72 | $reply->delete(); 73 | 74 | if (request()->expectsJson()) { 75 | return response(['status' => 'Reply deleted']); 76 | } 77 | 78 | return back(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /resources/assets/js/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * First we will load all of this project's JavaScript dependencies which 3 | * includes Vue and other libraries. It is a great starting point when 4 | * building robust, powerful web applications using Vue and Laravel. 5 | */ 6 | 7 | require("./bootstrap"); 8 | 9 | /** 10 | * Next, we will create a fresh Vue application instance and attach it to 11 | * the page. Then, you may begin adding components to this application 12 | * or customize the JavaScript scaffolding to fit your unique needs. 13 | */ 14 | 15 | Vue.component("flash", require("./components/Flash.vue")); 16 | Vue.component("paginator", require("./components/Paginator.vue")); 17 | Vue.component( 18 | "user-notifications", 19 | require("./components/UserNotifications.vue") 20 | ); 21 | Vue.component("avatar-form", require("./components/AvatarForm.vue")); 22 | Vue.component("activities", require("./components/Activities")); 23 | Vue.component("activity-layout", require("./components/ActivityLayout")); 24 | Vue.component("activity-favorite", require("./components/ActivityFavorite")); 25 | Vue.component("activity-reply", require("./components/ActivityReply")); 26 | Vue.component("activity-thread", require("./components/ActivityThread")); 27 | Vue.component("wysiwyg", require("./components/Wysiwyg.vue")); 28 | Vue.component("dropdown", require("./components/Dropdown.vue")); 29 | Vue.component("channel-dropdown", require("./components/ChannelDropdown.vue")); 30 | Vue.component("logout-button", require("./components/LogoutButton")); 31 | Vue.component("login", require("./components/Login")); 32 | Vue.component("register", require("./components/Register")); 33 | Vue.component("highlight", require("./components/Highlight")); 34 | Vue.component("leaderboard", require("./components/Leaderboard")); 35 | 36 | Vue.component("thread-view", require("./pages/Thread.vue")); 37 | 38 | const app = new Vue({ 39 | el: "#app", 40 | 41 | data: { 42 | searching: false 43 | }, 44 | 45 | methods: { 46 | search() { 47 | this.searching = true; 48 | 49 | this.$nextTick(() => { 50 | this.$refs.search.focus(); 51 | }); 52 | } 53 | } 54 | }); 55 | -------------------------------------------------------------------------------- /resources/assets/js/components/ChannelDropdown.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 50 | 51 | 76 | -------------------------------------------------------------------------------- /config/logging.php: -------------------------------------------------------------------------------- 1 | env('LOG_CHANNEL', 'stack'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Log Channels 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you may configure the log channels for your application. Out of 24 | | the box, Laravel uses the Monolog PHP logging library. This gives 25 | | you a variety of powerful log handlers / formatters to utilize. 26 | | 27 | | Available Drivers: "single", "daily", "slack", "syslog", 28 | | "errorlog", "custom", "stack" 29 | | 30 | */ 31 | 32 | 'channels' => [ 33 | 'stack' => [ 34 | 'driver' => 'stack', 35 | 'channels' => ['single'], 36 | ], 37 | 38 | 'single' => [ 39 | 'driver' => 'single', 40 | 'path' => storage_path('logs/laravel.log'), 41 | 'level' => 'debug', 42 | ], 43 | 44 | 'daily' => [ 45 | 'driver' => 'daily', 46 | 'path' => storage_path('logs/laravel.log'), 47 | 'level' => 'debug', 48 | 'days' => 7, 49 | ], 50 | 51 | 'slack' => [ 52 | 'driver' => 'slack', 53 | 'url' => env('LOG_SLACK_WEBHOOK_URL'), 54 | 'username' => 'Laravel Log', 55 | 'emoji' => ':boom:', 56 | 'level' => 'critical', 57 | ], 58 | 59 | 'syslog' => [ 60 | 'driver' => 'syslog', 61 | 'level' => 'debug', 62 | ], 63 | 64 | 'errorlog' => [ 65 | 'driver' => 'errorlog', 66 | 'level' => 'debug', 67 | ], 68 | ], 69 | 70 | ]; 71 | -------------------------------------------------------------------------------- /tests/Unit/TrendingTest.php: -------------------------------------------------------------------------------- 1 | trending = new Trending(); 20 | $this->trending->reset(); 21 | } 22 | 23 | /** @test */ 24 | public function it_increments_the_score_each_time_a_thread_is_pushed() 25 | { 26 | $thread = create('App\Thread'); 27 | 28 | $this->assertEquals(0, $this->trending->score($thread)); 29 | 30 | $this->trending->push($thread); 31 | 32 | $this->assertEquals(1, $this->trending->score($thread)); 33 | } 34 | 35 | /** @test */ 36 | public function it_returns_the_top_5_threads() 37 | { 38 | $thread1 = create('App\Thread'); 39 | $thread2 = create('App\Thread'); 40 | $thread3 = create('App\Thread'); 41 | $thread4 = create('App\Thread'); 42 | $thread5 = create('App\Thread'); 43 | $thread6 = create('App\Thread'); 44 | 45 | $this->trending->push($thread1, 1); 46 | $this->trending->push($thread2, 2); 47 | $this->trending->push($thread3, 3); 48 | $this->trending->push($thread4, 4); 49 | $this->trending->push($thread5, 5); 50 | $this->trending->push($thread6, 6); 51 | 52 | tap($this->trending->get(), function($trending) use ($thread1, $thread2, $thread3, $thread4, $thread5, $thread6) { 53 | $this->assertCount(5, $trending); 54 | $this->assertEquals($thread6->path(), $trending[0]->path); 55 | $this->assertEquals($thread5->path(), $trending[1]->path); 56 | $this->assertEquals($thread4->path(), $trending[2]->path); 57 | $this->assertEquals($thread3->path(), $trending[3]->path); 58 | $this->assertEquals($thread2->path(), $trending[4]->path); 59 | }); 60 | } 61 | 62 | /** @test */ 63 | public function it_returns_an_empty_list_when_there_are_no_trending_topics() { 64 | $this->assertCount(0, $this->trending->get()); 65 | } 66 | } 67 | --------------------------------------------------------------------------------