├── public ├── favicon.ico ├── robots.txt ├── vendor │ └── livewire │ │ └── manifest.json ├── images │ ├── favicon-tv.png │ └── larastreamers_social.png ├── mix-manifest.json ├── js │ └── app.js.LICENSE.txt ├── .htaccess ├── web.config └── index.php ├── database ├── .gitignore ├── seeders │ ├── DatabaseSeeder.php │ ├── UserTableSeeder.php │ └── TestDataSeeder.php ├── migrations │ ├── 2021_05_17_192445_add_tweeted_at_to_streams_table.php │ ├── 2021_05_18_135514_add_description_to_stream.php │ ├── 2021_07_12_163904_add_channel_id_to_streams.php │ ├── 2021_05_20_200000_add_language_code_to_streams_table.php │ ├── 2021_06_16_143518_add_language_code_to_channels.php │ ├── 2021_10_12_144648_add_auto_import_field_to_channels_table.php │ ├── 2021_07_12_164653_add_twitter_handle_to_channels_table.php │ ├── 2021_07_13_215242_add_hidden_at_to_streams_table.php │ ├── 2021_07_22_172021_add_announcement_tweeted_at_to_streams_table.php │ ├── 2021_05_15_142833_unique_youtube_id_in_streams_table.php │ ├── 2021_07_13_214007_add_actual_start_time_to_streams_table.php │ ├── 2021_06_23_201855_add_approval_fields_to_streams_table.php │ ├── 2021_05_13_104641_create_streams_table.php │ ├── 2014_10_12_100000_create_password_resets_table.php │ ├── 2021_05_15_203702_create_channels_table.php │ ├── 2019_08_19_000000_create_failed_jobs_table.php │ ├── 2021_05_13_114225_create_sessions_table.php │ ├── 2019_12_14_000001_create_personal_access_tokens_table.php │ ├── 2014_10_12_000000_create_users_table.php │ └── 2014_10_12_200000_add_two_factor_columns_to_users_table.php └── factories │ ├── ChannelFactory.php │ └── UserFactory.php ├── bootstrap ├── cache │ └── .gitignore └── app.php ├── storage ├── logs │ └── .gitignore ├── app │ ├── public │ │ └── .gitignore │ └── .gitignore └── framework │ ├── testing │ └── .gitignore │ ├── views │ └── .gitignore │ ├── cache │ ├── data │ │ └── .gitignore │ └── .gitignore │ ├── sessions │ └── .gitignore │ └── .gitignore ├── .github ├── FUNDING.yml └── workflows │ ├── format-php.yml │ └── run-tests.yml ├── resources ├── markdown │ ├── policy.md │ └── terms.md ├── js │ ├── app.js │ └── bootstrap.js ├── views │ ├── components │ │ ├── input-error.blade.php │ │ ├── stream-button.blade.php │ │ ├── nav-mobile-link.blade.php │ │ ├── icons │ │ │ ├── close.blade.php │ │ │ ├── download.blade.php │ │ │ ├── calendar.blade.php │ │ │ ├── chevron-down.blade.php │ │ │ ├── search.blade.php │ │ │ ├── icon-time.blade.php │ │ │ ├── icon-user.blade.php │ │ │ ├── marker.blade.php │ │ │ ├── twitter.blade.php │ │ │ ├── youtube.blade.php │ │ │ └── world.blade.php │ │ ├── nav-link.blade.php │ │ ├── page-header.blade.php │ │ ├── local-time.blade.php │ │ ├── search.blade.php │ │ ├── main-layout.blade.php │ │ ├── streamer-channel.blade.php │ │ └── add-streams-to-calendar.blade.php │ ├── pages │ │ ├── streamApproved.blade.php │ │ ├── streamRejected.blade.php │ │ ├── archive.blade.php │ │ ├── home.blade.php │ │ ├── partials │ │ │ ├── header-home.blade.php │ │ │ ├── empty-stream-list.blade.php │ │ │ ├── live-indicator.blade.php │ │ │ ├── meta.blade.php │ │ │ ├── header │ │ │ │ ├── slogan.blade.php │ │ │ │ └── preview.blade.php │ │ │ └── footer.blade.php │ │ └── streamers.blade.php │ ├── mail │ │ ├── approved.blade.php │ │ ├── rejected.blade.php │ │ └── submitted.blade.php │ ├── api │ │ └── index.blade.php │ ├── dashboard.blade.php │ ├── terms.blade.php │ ├── policy.blade.php │ ├── layouts │ │ ├── guest.blade.php │ │ └── app.blade.php │ ├── auth │ │ ├── confirm-password.blade.php │ │ ├── forgot-password.blade.php │ │ ├── verify-email.blade.php │ │ ├── reset-password.blade.php │ │ └── login.blade.php │ ├── livewire │ │ ├── timezone-switcher.blade.php │ │ ├── stream-list.blade.php │ │ └── stream-list-archive.blade.php │ └── profile │ │ ├── show.blade.php │ │ └── update-password-form.blade.php ├── css │ └── app.css └── lang │ ├── en │ ├── pagination.php │ ├── auth.php │ └── passwords.php │ └── vendor │ └── backup │ ├── zh-CN │ └── notifications.php │ ├── zh-TW │ └── notifications.php │ └── ja │ └── notifications.php ├── .gitattributes ├── routes ├── api │ └── v1.php ├── channels.php ├── api.php ├── console.php └── web.php ├── .styleci.yml ├── .editorconfig ├── .gitignore ├── app ├── Facades │ └── YouTube.php ├── Http │ ├── Middleware │ │ ├── EncryptCookies.php │ │ ├── VerifyCsrfToken.php │ │ ├── TrustHosts.php │ │ ├── PreventRequestsDuringMaintenance.php │ │ ├── TrimStrings.php │ │ ├── Authenticate.php │ │ ├── TrustProxies.php │ │ └── RedirectIfAuthenticated.php │ ├── Controllers │ │ ├── PageHomeController.php │ │ ├── Controller.php │ │ ├── Submission │ │ │ ├── ApproveStreamController.php │ │ │ ├── RejectStreamController.php │ │ │ └── SubmitStreamController.php │ │ ├── PageStreamersController.php │ │ ├── AddSingleStreamToCalendarController.php │ │ ├── Api │ │ │ └── V1 │ │ │ │ └── Streams │ │ │ │ └── IndexController.php │ │ └── CalendarController.php │ ├── Livewire │ │ ├── StreamList.php │ │ ├── StreamListArchive.php │ │ ├── ImportYouTubeLiveStream.php │ │ ├── ImportYouTubeChannel.php │ │ └── SubmitYouTubeLiveStream.php │ └── Resources │ │ └── StreamResource.php ├── View │ └── Components │ │ ├── AppLayout.php │ │ ├── GuestLayout.php │ │ ├── InputError.php │ │ ├── LocalTime.php │ │ ├── StreamButton.php │ │ ├── NavLink.php │ │ ├── NavMobileLink.php │ │ ├── AddStreamsToCalendar.php │ │ └── MainLayout.php ├── Actions │ ├── Submission │ │ ├── RejectStreamAction.php │ │ ├── ApproveStreamAction.php │ │ └── SubmitStreamAction.php │ ├── Fortify │ │ ├── PasswordValidationRules.php │ │ ├── ResetUserPassword.php │ │ ├── CreateNewUser.php │ │ ├── UpdateUserPassword.php │ │ └── UpdateUserProfileInformation.php │ ├── Jetstream │ │ └── DeleteUser.php │ ├── ImportVideoAction.php │ ├── CollectTimezones.php │ └── SortStreamsByDateAction.php ├── Providers │ ├── BroadcastServiceProvider.php │ ├── AppServiceProvider.php │ ├── AuthServiceProvider.php │ ├── TwitterServiceProvider.php │ ├── EventServiceProvider.php │ ├── JetstreamServiceProvider.php │ └── FortifyServiceProvider.php ├── Services │ ├── Twitter.php │ └── YouTube │ │ ├── YouTubeException.php │ │ ├── ChannelData.php │ │ └── StreamData.php ├── Mail │ ├── StreamSubmittedMail.php │ ├── StreamApprovedMail.php │ └── StreamRejectedMail.php ├── Console │ └── Commands │ │ ├── ImportChannelStreamsCommand.php │ │ ├── TweetAboutWeeklySummaryCommand.php │ │ ├── TweetAboutLiveStreamsCommand.php │ │ ├── TweetAboutUpcomingStreamsCommand.php │ │ ├── CheckIfLiveStreamsHaveEndedCommand.php │ │ ├── CheckIfUpcomingStreamsAreLiveCommand.php │ │ └── ImportChannelsForStreamsCommand.php ├── Exceptions │ └── Handler.php ├── Rules │ └── YouTubeRule.php ├── Models │ ├── Channel.php │ └── User.php └── Jobs │ ├── TweetStreamIsLiveJob.php │ ├── TweetStreamIsUpcomingJob.php │ └── ImportYoutubeChannelStreamsJob.php ├── tests ├── CreatesApplication.php ├── Feature │ ├── Http │ │ └── Controllers │ │ │ ├── Submission │ │ │ ├── SubmissionModalTest.php │ │ │ ├── RejectStreamControllerTest.php │ │ │ └── ApproveStreamControllerTest.php │ │ │ └── Api │ │ │ └── V1 │ │ │ └── Streams │ │ │ └── IndexControllerTest.php │ ├── JetStream │ │ ├── BrowserSessionsTest.php │ │ ├── AuthenticationTest.php │ │ ├── ProfileInformationTest.php │ │ ├── DeleteApiTokenTest.php │ │ ├── PasswordConfirmationTest.php │ │ ├── DeleteAccountTest.php │ │ ├── CreateApiTokenTest.php │ │ └── ApiTokenPermissionsTest.php │ ├── PagesResponseTest.php │ ├── Commands │ │ ├── ImportChannelStreamsCommandTest.php │ │ ├── CheckIfLiveStreamsHaveEndedCommandTest.php │ │ └── CheckIfUpcomingStreamsAreLiveCommandTest.php │ ├── PageDashboardTest.php │ ├── Actions │ │ └── Submission │ │ │ ├── RejectStreamActionTest.php │ │ │ ├── SubmitStreamActionTest.php │ │ │ └── ApproveStreamActionTest.php │ ├── FeedTest.php │ └── Models │ │ └── StreamTest.php ├── Fakes │ └── TwitterFake.php └── TestCase.php ├── server.php ├── webpack.mix.js ├── config ├── cors.php ├── feed.php ├── view.php ├── services.php ├── hashing.php ├── sanctum.php └── broadcasting.php ├── package.json ├── .env.example ├── phpunit.xml.dist ├── tailwind.config.js └── artisan /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite* 2 | -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /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/testing/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/sessions/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !data/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /public/vendor/livewire/manifest.json: -------------------------------------------------------------------------------- 1 | {"/livewire.js":"/livewire.js?id=21fa1dd78491a49255cd"} -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [christophrumpel] 4 | 5 | -------------------------------------------------------------------------------- /public/images/favicon-tv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tweichart/larastreamers/main/public/images/favicon-tv.png -------------------------------------------------------------------------------- /resources/markdown/policy.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | Edit this file to define the privacy policy for your application. 4 | -------------------------------------------------------------------------------- /resources/markdown/terms.md: -------------------------------------------------------------------------------- 1 | # Terms of Service 2 | 3 | Edit this file to define the terms of service for your application. 4 | -------------------------------------------------------------------------------- /public/images/larastreamers_social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tweichart/larastreamers/main/public/images/larastreamers_social.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.css linguist-vendored 3 | *.scss linguist-vendored 4 | *.js linguist-vendored 5 | CHANGELOG.md export-ignore 6 | -------------------------------------------------------------------------------- /resources/js/app.js: -------------------------------------------------------------------------------- 1 | require('./bootstrap'); 2 | 3 | require('alpinejs'); 4 | window.moment = require('moment'); 5 | require('moment-timezone'); 6 | -------------------------------------------------------------------------------- /resources/views/components/input-error.blade.php: -------------------------------------------------------------------------------- 1 |
3 | {{ $message }} 4 |
5 | -------------------------------------------------------------------------------- /public/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/app.js": "/js/app.js?id=814d2d9069235f46192c", 3 | "/css/app.css": "/css/app.css?id=41ae9bd11f5c3f127351" 4 | } 5 | -------------------------------------------------------------------------------- /resources/css/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | 5 | [x-cloak] { 6 | display: none; 7 | } -------------------------------------------------------------------------------- /storage/framework/.gitignore: -------------------------------------------------------------------------------- 1 | compiled.php 2 | config.php 3 | down 4 | events.scanned.php 5 | maintenance.php 6 | routes.php 7 | routes.scanned.php 8 | schedule-* 9 | services.json 10 | -------------------------------------------------------------------------------- /routes/api/v1.php: -------------------------------------------------------------------------------- 1 | name('streams'); 7 | -------------------------------------------------------------------------------- /resources/views/pages/streamApproved.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |

Stream approved!

4 |

The stream has been approved.

5 |
6 |
7 | -------------------------------------------------------------------------------- /resources/views/pages/streamRejected.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |

Stream rejected!

4 |

The stream has been rejected.

5 |
6 |
7 | -------------------------------------------------------------------------------- /resources/views/pages/archive.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | php: 2 | preset: laravel 3 | disabled: 4 | - no_unused_imports 5 | finder: 6 | not-name: 7 | - index.php 8 | - server.php 9 | js: 10 | finder: 11 | not-name: 12 | - webpack.mix.js 13 | css: true 14 | -------------------------------------------------------------------------------- /resources/views/components/stream-button.blade.php: -------------------------------------------------------------------------------- 1 | 3 | {{ $slot }} 4 | {{ $name }} 5 | 6 | -------------------------------------------------------------------------------- /resources/views/components/nav-mobile-link.blade.php: -------------------------------------------------------------------------------- 1 | 2 | {{ $name }} 3 | 4 | -------------------------------------------------------------------------------- /resources/views/pages/home.blade.php: -------------------------------------------------------------------------------- 1 | 2 | @include('pages.partials.header-home') 3 | 4 |
5 | 6 |
7 |
8 | -------------------------------------------------------------------------------- /resources/views/mail/approved.blade.php: -------------------------------------------------------------------------------- 1 | @component('mail::message') 2 | Hi, 3 | 4 | Your stream [{{ $stream->title }}]({{ $stream->url() }}) was approved.🥳 You can now find it on our [homepage]({{ route('home') }}). 5 | 6 | Thanks, 7 | 8 | 9 | Christoph 10 | @endcomponent 11 | -------------------------------------------------------------------------------- /resources/views/mail/rejected.blade.php: -------------------------------------------------------------------------------- 1 | @component('mail::message') 2 | Hi, 3 | 4 | Your stream [{{ $stream->title }}]({{ $stream->url() }}) was rejected. 5 | Maybe it wasn't a good fit, but please try it again the next time. 6 | 7 | Thanks, 8 | 9 | Christoph 10 | @endcomponent 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /public/hot 3 | /public/storage 4 | /storage/*.key 5 | /vendor 6 | /.idea 7 | .env 8 | .env.backup 9 | .phpunit.result.cache 10 | docker-compose.override.yml 11 | Homestead.json 12 | Homestead.yaml 13 | npm-debug.log 14 | yarn-error.log 15 | .php-cs-fixer.cache 16 | phpunit.xml 17 | -------------------------------------------------------------------------------- /resources/views/components/icons/close.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => '']) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/views/components/nav-link.blade.php: -------------------------------------------------------------------------------- 1 | 2 | {{ $name }} 3 | 4 | -------------------------------------------------------------------------------- /resources/views/pages/partials/header-home.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | @include('pages.partials.nav') 4 | 5 |
6 | @include('pages.partials.header.slogan') 7 | @include('pages.partials.header.preview') 8 |
9 |
10 | -------------------------------------------------------------------------------- /app/Facades/YouTube.php: -------------------------------------------------------------------------------- 1 | call(UserTableSeeder::class); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/Http/Middleware/EncryptCookies.php: -------------------------------------------------------------------------------- 1 | submitted_by_email)->queue(new StreamRejectedMail($stream)); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/Http/Controllers/PageHomeController.php: -------------------------------------------------------------------------------- 1 | Stream::getNextUpcomingOrLive(), 14 | ]); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/View/Components/GuestLayout.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | {{ __('API Tokens') }} 5 |

6 |
7 | 8 |
9 |
10 | @livewire('api.api-token-manager') 11 |
12 |
13 | 14 | -------------------------------------------------------------------------------- /resources/views/mail/submitted.blade.php: -------------------------------------------------------------------------------- 1 | @component('mail::message') 2 | Hi, 3 | 4 | A new stream was submitted: 5 | * title: "[{{ $stream->title }}]({{ $stream->url() }})" 6 | * by: {{ $stream->submitted_by_email }} 7 | * language: {{ $stream->language_code }} 8 | 9 | 🟢 [Approve]({{ $stream->approveUrl() }}) 10 | 11 | 🔴 [Reject]({{ $stream->rejectUrl() }}) 12 | 13 | Greets, 14 | 15 | Larastreamers 16 | @endcomponent 17 | -------------------------------------------------------------------------------- /resources/views/components/icons/download.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => '']) }}xmlns="http://www.w3.org/2000/svg" 2 | fill="none" 3 | viewBox="0 0 24 24" 4 | stroke="currentColor" 5 | > 6 | 11 | 12 | -------------------------------------------------------------------------------- /app/View/Components/LocalTime.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | @include('pages.partials.nav') 4 | 5 |
6 |

{{ $title }}

7 | {{ $slot }} 8 |
9 | 10 | -------------------------------------------------------------------------------- /app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | merge(['class' => '']) }} xmlns="http://www.w3.org/2000/svg" 2 | fill="none" 3 | viewBox="0 0 24 24" 4 | stroke="currentColor" 5 | > 6 | 11 | 12 | -------------------------------------------------------------------------------- /app/Http/Controllers/Submission/ApproveStreamController.php: -------------------------------------------------------------------------------- 1 | handle($stream); 13 | 14 | return view('pages.streamApproved'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /resources/views/components/icons/chevron-down.blade.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/Http/Middleware/TrustHosts.php: -------------------------------------------------------------------------------- 1 | allSubdomainsOfApplicationUrl(), 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/Http/Middleware/PreventRequestsDuringMaintenance.php: -------------------------------------------------------------------------------- 1 | merge(['class' => '']) }} viewBox="0 0 140 140" height="140" width="140" xmlns="http://www.w3.org/2000/svg"> 2 | 3 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /database/migrations/2021_05_17_192445_add_tweeted_at_to_streams_table.php: -------------------------------------------------------------------------------- 1 | timestamp('tweeted_at')->nullable(); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /resources/views/dashboard.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | {{ __('Dashboard') }} 5 |

6 |
7 | 8 |
9 |
10 | 11 | 12 | 13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /app/Http/Controllers/Submission/RejectStreamController.php: -------------------------------------------------------------------------------- 1 | handle($stream); 14 | 15 | return view('pages.streamRejected'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /database/migrations/2021_05_18_135514_add_description_to_stream.php: -------------------------------------------------------------------------------- 1 | text('description')->nullable()->after('title'); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /resources/views/terms.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 | 6 |
7 | 8 |
9 | {!! $terms !!} 10 |
11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /app/Actions/Jetstream/DeleteUser.php: -------------------------------------------------------------------------------- 1 | deleteProfilePhoto(); 18 | $user->tokens->each->delete(); 19 | $user->delete(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/View/Components/NavLink.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 | 6 |
7 | 8 |
9 | {!! $policy !!} 10 |
11 |
12 |
13 | 14 | -------------------------------------------------------------------------------- /tests/CreatesApplication.php: -------------------------------------------------------------------------------- 1 | make(Kernel::class)->bootstrap(); 19 | 20 | return $app; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/Providers/BroadcastServiceProvider.php: -------------------------------------------------------------------------------- 1 | string('channel_id')->after('youtube_id')->nullable(); 13 | }); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /app/Http/Controllers/Submission/SubmitStreamController.php: -------------------------------------------------------------------------------- 1 | handle($request->youtube_id, $request->email); 13 | 14 | return response()->noContent(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /database/migrations/2021_05_20_200000_add_language_code_to_streams_table.php: -------------------------------------------------------------------------------- 1 | string('language_code')->default('en')->after('status'); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/View/Components/NavMobileLink.php: -------------------------------------------------------------------------------- 1 | string('language_code')->after('platform_id')->default('en'); 13 | }); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /app/Http/Controllers/PageStreamersController.php: -------------------------------------------------------------------------------- 1 | orderBy('approved_finished_streams_count', 'Desc')->get(); 14 | 15 | return view('pages.streamers', ['channels' => $channels]); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /database/migrations/2021_10_12_144648_add_auto_import_field_to_channels_table.php: -------------------------------------------------------------------------------- 1 | boolean('auto_import')->default(false)->after('country'); 13 | }); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /database/migrations/2021_07_12_164653_add_twitter_handle_to_channels_table.php: -------------------------------------------------------------------------------- 1 | string('twitter_handle')->nullable()->after('on_platform_since'); 13 | }); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/format-php.yml: -------------------------------------------------------------------------------- 1 | name: Format (PHP) 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - '**.php' 8 | 9 | jobs: 10 | php-cs-fixer: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Install 16 | run: composer install 17 | 18 | - name: Run php-cs-fixer 19 | run: ./vendor/bin/php-cs-fixer fix 20 | 21 | - uses: stefanzweifel/git-auto-commit-action@v4 22 | with: 23 | commit_message: Apply php-cs-fixer changes 24 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | twitter = $twitter; 14 | } 15 | 16 | public function tweet(string $status) 17 | { 18 | if (! app()->environment('production')) { 19 | return; 20 | } 21 | 22 | return (array) $this->twitter->post('statuses/update', compact('status')); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /resources/views/components/icons/icon-time.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => '']) }} viewBox="0 0 140 140" height="140" width="140" 2 | xmlns="http://www.w3.org/2000/svg"> 3 | 4 | 6 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/components/local-time.blade.php: -------------------------------------------------------------------------------- 1 | 14 | {{ $date->format('Y-m-d H:i') }} 15 | 16 | -------------------------------------------------------------------------------- /database/seeders/UserTableSeeder.php: -------------------------------------------------------------------------------- 1 | 'Christoph', 21 | 'email' => 'christoph@christoph-rumpel.com', 22 | 'password' => bcrypt('password'), 23 | ]); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /resources/views/components/icons/icon-user.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => '']) }} viewBox="0 0 140 140" height="140" width="140" 2 | xmlns="http://www.w3.org/2000/svg"> 3 | 4 | 6 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/Actions/Submission/ApproveStreamAction.php: -------------------------------------------------------------------------------- 1 | approved_at)) { 14 | return; 15 | } 16 | 17 | $stream->approved_at = now(); 18 | 19 | $stream->save(); 20 | 21 | Mail::to($stream->submitted_by_email)->queue(new StreamApprovedMail($stream)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/Http/Middleware/Authenticate.php: -------------------------------------------------------------------------------- 1 | expectsJson()) { 18 | return route('login'); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/View/Components/AddStreamsToCalendar.php: -------------------------------------------------------------------------------- 1 | webcalLink = $webcalLink; 17 | } 18 | 19 | public function render() 20 | { 21 | return view('components.add-streams-to-calendar'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/View/Components/MainLayout.php: -------------------------------------------------------------------------------- 1 | timestamp('hidden_at')->nullable()->after('approved_at'); 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /resources/views/components/icons/marker.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => '']) }} viewBox="0 0 140 140" height="140" width="140" 2 | xmlns="http://www.w3.org/2000/svg"> 3 | 4 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /resources/views/components/icons/twitter.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => '']) }} width="1792" height="1792" viewBox="0 0 1792 1792" 2 | xmlns="http://www.w3.org/2000/svg"> 3 | 5 | 6 | -------------------------------------------------------------------------------- /resources/views/pages/partials/empty-stream-list.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
    4 |

    5 | There are no upcoming streams 😢
    6 | Be the first the next one. 7 |

    8 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /app/Mail/StreamSubmittedMail.php: -------------------------------------------------------------------------------- 1 | subject('Stream submitted on Larastreamers') 23 | ->markdown('mail.submitted'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /resources/views/pages/partials/live-indicator.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
4 |
5 |
6 |
7 | 8 | live 9 |
10 |
11 | -------------------------------------------------------------------------------- /app/Mail/StreamApprovedMail.php: -------------------------------------------------------------------------------- 1 | subject("The stream you've submitted has been approved") 23 | ->markdown('mail.approved'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/Mail/StreamRejectedMail.php: -------------------------------------------------------------------------------- 1 | subject("The stream you've submitted was rejected") 23 | ->markdown('mail.rejected'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /database/migrations/2021_07_22_172021_add_announcement_tweeted_at_to_streams_table.php: -------------------------------------------------------------------------------- 1 | timestamp('upcoming_tweeted_at')->nullable()->after('tweeted_at'); 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Feature/Http/Controllers/Submission/SubmissionModalTest.php: -------------------------------------------------------------------------------- 1 | get(route('home')) 14 | ->assertSeeLivewire(SubmitYouTubeLiveStream::class); 15 | 16 | $this->get(route('archive')) 17 | ->assertSeeLivewire(SubmitYouTubeLiveStream::class); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /resources/lang/en/pagination.php: -------------------------------------------------------------------------------- 1 | '« Previous', 17 | 'next' => 'Next »', 18 | 19 | ]; 20 | -------------------------------------------------------------------------------- /routes/channels.php: -------------------------------------------------------------------------------- 1 | id === (int) $id; 18 | }); 19 | -------------------------------------------------------------------------------- /resources/views/pages/streamers.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |
8 | @foreach($channels as $channel) 9 | 10 | @endforeach 11 |
12 |
13 |
14 | 15 |
16 | -------------------------------------------------------------------------------- /app/Services/YouTube/YouTubeException.php: -------------------------------------------------------------------------------- 1 | actingAs($user = User::factory()->create()); 15 | 16 | Livewire::test(LogoutOtherBrowserSessionsForm::class) 17 | ->set('password', 'password') 18 | ->call('logoutOtherBrowserSessions'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | get('/user', function(Request $request) { 18 | return $request->user(); 19 | }); 20 | -------------------------------------------------------------------------------- /server.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | 10 | $uri = urldecode( 11 | parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) 12 | ); 13 | 14 | // This file allows us to emulate Apache's "mod_rewrite" functionality from the 15 | // built-in PHP web server. This provides a convenient way to test a Laravel 16 | // application without having installed a "real" web server software here. 17 | if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) { 18 | return false; 19 | } 20 | 21 | require_once __DIR__.'/public/index.php'; 22 | -------------------------------------------------------------------------------- /public/js/app.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Lodash 4 | * Copyright OpenJS Foundation and other contributors 5 | * Released under MIT license 6 | * Based on Underscore.js 1.8.3 7 | * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 8 | */ 9 | 10 | //! Copyright (c) JS Foundation and other contributors 11 | 12 | //! github.com/moment/moment-timezone 13 | 14 | //! license : MIT 15 | 16 | //! moment-timezone.js 17 | 18 | //! moment.js 19 | 20 | //! moment.js locale configuration 21 | 22 | //! version : 0.5.33 23 | -------------------------------------------------------------------------------- /routes/console.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 19 | })->purpose('Display an inspiring quote'); 20 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Redirect Trailing Slashes If Not A Folder... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Send Requests To Front Controller... 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | -------------------------------------------------------------------------------- /app/Actions/Submission/SubmitStreamAction.php: -------------------------------------------------------------------------------- 1 | handle( 14 | $youTubeId, 15 | $languageCode, 16 | approved: false, 17 | submittedByEmail: $submittedByEmail, 18 | ); 19 | 20 | Mail::to('christoph@christoph-rumpel.com')->queue(new StreamSubmittedMail($stream)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/Providers/AuthServiceProvider.php: -------------------------------------------------------------------------------- 1 | 'App\Policies\ModelPolicy', 16 | ]; 17 | 18 | /** 19 | * Register any authentication / authorization services. 20 | * 21 | * @return void 22 | */ 23 | public function boot() 24 | { 25 | $this->registerPolicies(); 26 | 27 | // 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/Http/Middleware/TrustProxies.php: -------------------------------------------------------------------------------- 1 | unique(['youtube_id']); 14 | }); 15 | } 16 | 17 | public function down(): void 18 | { 19 | Schema::table('streams', static function (Blueprint $table) { 20 | $table->dropUnique(['youtube_id']); 21 | }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /database/migrations/2021_07_13_214007_add_actual_start_time_to_streams_table.php: -------------------------------------------------------------------------------- 1 | timestamp('actual_start_time')->nullable()->after('scheduled_start_time'); 18 | $table->timestamp('actual_end_time')->nullable()->after('actual_start_time'); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /resources/views/components/search.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | merge(['class' => 'w-80 text-gray-dark px-8 py-4 placeholder-gray-light border-0 focus:border-red focus:ring-red', 'id' => 'search', 'placeholder' => 'Search for a stream...', 'role' => 'search', 'aria-placeholder' => "A stream's title"]) }} /> 5 |
6 | 7 |
8 |
9 |
10 | -------------------------------------------------------------------------------- /app/Console/Commands/ImportChannelStreamsCommand.php: -------------------------------------------------------------------------------- 1 | get() 19 | ->each(fn(Channel $channel) => dispatch(new ImportYoutubeChannelStreamsJob($channel->platform_id, $channel->language_code))); 20 | 21 | return self::SUCCESS; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /resources/views/components/icons/youtube.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => '']) }} width="1792" height="1792" viewBox="0 0 1792 1792" 2 | xmlns="http://www.w3.org/2000/svg"> 3 | 5 | 6 | -------------------------------------------------------------------------------- /resources/views/components/icons/world.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => '']) }} viewBox="0 0 140 140" height="140" width="140" 2 | xmlns="http://www.w3.org/2000/svg"> 3 | 4 | 6 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /resources/views/pages/partials/meta.blade.php: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 7 | 9 | 10 | 12 | 13 | 15 | 16 | 18 | 19 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/Http/Livewire/StreamList.php: -------------------------------------------------------------------------------- 1 | upcomingOrLive()->fromOldestToLatest() 19 | ->paginate(10); 20 | 21 | return view('livewire.stream-list', [ 22 | 'streamsByDate' => $streams->setCollection( 23 | (new SortStreamsByDateAction())->handle($streams->getCollection()) 24 | ), 25 | ]); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /resources/lang/en/auth.php: -------------------------------------------------------------------------------- 1 | 'These credentials do not match our records.', 17 | 'password' => 'The provided password is incorrect.', 18 | 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 19 | 20 | ]; 21 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix'); 2 | 3 | /* 4 | |-------------------------------------------------------------------------- 5 | | Mix Asset Management 6 | |-------------------------------------------------------------------------- 7 | | 8 | | Mix provides a clean, fluent API for defining some Webpack build steps 9 | | for your Laravel applications. By default, we are compiling the CSS 10 | | file for the application as well as bundling up all the JS files. 11 | | 12 | */ 13 | 14 | mix.js('resources/js/app.js', 'public/js') 15 | .postCss('resources/css/app.css', 'public/css', [ 16 | require('postcss-import'), 17 | require('tailwindcss'), 18 | ]); 19 | 20 | mix.version(); 21 | 22 | mix.browserSync('larastreamers.test/'); 23 | 24 | -------------------------------------------------------------------------------- /app/Providers/TwitterServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->bind(Twitter::class, function() { 14 | $connection = new TwitterOAuth( 15 | config('services.twitter.consumer_key'), 16 | config('services.twitter.consumer_secret'), 17 | config('services.twitter.access_token'), 18 | config('services.twitter.access_token_secret') 19 | ); 20 | 21 | return new Twitter($connection); 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /database/migrations/2021_06_23_201855_add_approval_fields_to_streams_table.php: -------------------------------------------------------------------------------- 1 | timestamp('approved_at')->nullable()->after('language_code'); 14 | $table->string('submitted_by_email')->nullable()->after('language_code'); 15 | }); 16 | 17 | Stream::each(function(Stream $stream) { 18 | $stream->update(['approved_at' => now()]); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/Http/Controllers/AddSingleStreamToCalendarController.php: -------------------------------------------------------------------------------- 1 | name("Larastreamers: watch {$stream->title}") 14 | ->description('There is no better way to learn than by watching other developers code live. Find out who is streaming next in the Laravel world.'); 15 | 16 | $calendar->event($stream->toCalendarItem()); 17 | 18 | return response($calendar->get()) 19 | ->header('Content-Type', 'text/calendar'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/Actions/Fortify/ResetUserPassword.php: -------------------------------------------------------------------------------- 1 | $this->passwordRules(), 24 | ])->validate(); 25 | 26 | $user->forceFill([ 27 | 'password' => Hash::make($input['password']), 28 | ])->save(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Providers/EventServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 19 | SendEmailVerificationNotification::class, 20 | ], 21 | ]; 22 | 23 | /** 24 | * Register any events for your application. 25 | * 26 | * @return void 27 | */ 28 | public function boot() 29 | { 30 | // 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /resources/lang/en/passwords.php: -------------------------------------------------------------------------------- 1 | 'Your password has been reset!', 17 | 'sent' => 'We have emailed your password reset link!', 18 | 'throttled' => 'Please wait before retrying.', 19 | 'token' => 'This password reset token is invalid.', 20 | 'user' => "We can't find a user with that email address.", 21 | 22 | ]; 23 | -------------------------------------------------------------------------------- /app/Http/Controllers/Api/V1/Streams/IndexController.php: -------------------------------------------------------------------------------- 1 | approved()->latest()->paginate(), 19 | ), 20 | status: Response::HTTP_OK, 21 | headers: [ 22 | 'Content-Type' => 'application/vnd.api+json', 23 | ], 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /database/migrations/2021_05_13_104641_create_streams_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 14 | $table->string('youtube_id'); 15 | $table->string('channel_title'); 16 | $table->string('title'); 17 | $table->string('thumbnail_url'); 18 | $table->dateTime('scheduled_start_time'); 19 | $table->string('status')->default(StreamData::STATUS_UPCOMING); 20 | $table->timestamps(); 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Feature/PagesResponseTest.php: -------------------------------------------------------------------------------- 1 | get(route('home')) 13 | ->assertOk(); 14 | } 15 | 16 | /** @test */ 17 | public function it_can_show_the_feed(): void 18 | { 19 | $this->get('/feed') 20 | ->assertOk(); 21 | } 22 | 23 | /** @test */ 24 | public function it_can_show_the_archive(): void 25 | { 26 | $this->get(route('archive')) 27 | ->assertOk(); 28 | } 29 | 30 | /** @test */ 31 | public function it_can_show_the_calendar_page(): void 32 | { 33 | $this->get(route('calendar.ics')) 34 | ->assertOk(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Feature/Commands/ImportChannelStreamsCommandTest.php: -------------------------------------------------------------------------------- 1 | autoImportEnabled() 20 | ->count(2) 21 | ->create(); 22 | 23 | // Act 24 | $this->artisan(ImportChannelStreamsCommand::class); 25 | 26 | // Assert 27 | Queue::assertPushed(ImportYoutubeChannelStreamsJob::class, 2); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/Http/Livewire/StreamListArchive.php: -------------------------------------------------------------------------------- 1 | ['except' => ''], 15 | ]; 16 | 17 | public ?string $search = null; 18 | 19 | public function updatedSearch(): void 20 | { 21 | $this->resetPage(); 22 | } 23 | 24 | public function render() 25 | { 26 | return view('livewire.stream-list-archive', [ 27 | 'streams' => Stream::query() 28 | ->approved() 29 | ->finished() 30 | ->search($this->search) 31 | ->fromLatestToOldest() 32 | ->paginate(24), 33 | ]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Feature/PageDashboardTest.php: -------------------------------------------------------------------------------- 1 | actingAs(User::factory()->create()) 16 | ->get(route('dashboard')) 17 | ->assertSeeLivewire(ImportYouTubeLiveStream::class); 18 | } 19 | 20 | /** @test */ 21 | public function it_includes_livewire_youtube_import_channel_component(): void 22 | { 23 | $this->actingAs(User::factory()->create()) 24 | ->get(route('dashboard')) 25 | ->assertSeeLivewire(ImportYouTubeChannel::class); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_100000_create_password_resets_table.php: -------------------------------------------------------------------------------- 1 | string('email')->index(); 18 | $table->string('token'); 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 | -------------------------------------------------------------------------------- /resources/views/layouts/guest.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ config('app.name', 'Laravel') }} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | {{ $slot }} 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /app/Http/Middleware/RedirectIfAuthenticated.php: -------------------------------------------------------------------------------- 1 | check()) { 26 | return redirect(RouteServiceProvider::HOME); 27 | } 28 | } 29 | 30 | return $next($request); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Services/YouTube/ChannelData.php: -------------------------------------------------------------------------------- 1 | $this->platformId, 22 | 'slug' => $this->slug, 23 | 'name' => $this->name, 24 | 'description' => $this->description, 25 | 'on_platform_since' => $this->onPlatformSince, 26 | 'thumbnail_url' => $this->thumbnailUrl, 27 | 'country' => $this->country, 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Feature/Http/Controllers/Submission/RejectStreamControllerTest.php: -------------------------------------------------------------------------------- 1 | notApproved() 19 | ->create([ 20 | 'submitted_by_email' => 'john@example.com', 21 | ]); 22 | 23 | // Assert 24 | $this->assertFalse($stream->isApproved()); 25 | 26 | // Act 27 | $this->get($stream->rejectUrl()) 28 | ->assertOk(); 29 | 30 | // Assert 31 | $this->assertFalse($stream->refresh()->isApproved()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /resources/views/pages/partials/header/slogan.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 6 |

Watch other developers code live

8 |

9 | There is no better way to learn than by watching other developers 10 | code live. Find out who is streaming next in the Laravel world. 11 |

12 | @if($upcomingStream) 13 | 14 | @endif 15 |
16 | 17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /tests/Fakes/TwitterFake.php: -------------------------------------------------------------------------------- 1 | lastTweetStatus = $status; 15 | $this->tweetsSent++; 16 | } 17 | 18 | public function assertTweetWasSent(): void 19 | { 20 | PHPUnit::assertTrue($this->tweetsSent > 0, 'Error checking if a tweet was sent.'); 21 | } 22 | 23 | public function assertNoTweetsWereSent(): void 24 | { 25 | PHPUnit::assertEquals(0, $this->tweetsSent, "There should not be any tweets sent but there are {$this->tweetsSent} tweets send."); 26 | } 27 | 28 | public function assertLastTweetMessageWas(string $expectedStatus): void 29 | { 30 | PHPUnit::assertEquals($expectedStatus, $this->lastTweetStatus); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Exceptions/Handler.php: -------------------------------------------------------------------------------- 1 | reportable(function(Throwable $e) { 38 | // 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/Http/Livewire/ImportYouTubeLiveStream.php: -------------------------------------------------------------------------------- 1 | handle($this->youTubeId, $this->language, approved: true); 24 | } catch (YouTubeException $exception) { 25 | return $this->addError('stream', $exception->getMessage()); 26 | } 27 | 28 | session()->flash('stream-message', 'Stream "'.$this->youTubeId.'" was added successfully.'); 29 | 30 | $this->reset(['youTubeId']); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /database/migrations/2021_05_15_203702_create_channels_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 13 | $table->string('platform')->default('youtube'); 14 | $table->string('platform_id'); 15 | $table->string('slug'); 16 | $table->string('name'); 17 | $table->text('description'); 18 | $table->string('thumbnail_url'); 19 | $table->string('country'); 20 | $table->dateTime('on_platform_since'); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | public function down(): void 26 | { 27 | Schema::dropIfExists('channels'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/cors.php: -------------------------------------------------------------------------------- 1 | ['api/*', 'sanctum/csrf-cookie'], 19 | 20 | 'allowed_methods' => ['*'], 21 | 22 | 'allowed_origins' => ['*'], 23 | 24 | 'allowed_origins_patterns' => [], 25 | 26 | 'allowed_headers' => ['*'], 27 | 28 | 'exposed_headers' => [], 29 | 30 | 'max_age' => 0, 31 | 32 | 'supports_credentials' => false, 33 | 34 | ]; 35 | -------------------------------------------------------------------------------- /resources/js/bootstrap.js: -------------------------------------------------------------------------------- 1 | window._ = require('lodash'); 2 | 3 | /** 4 | * We'll load the axios HTTP library which allows us to easily issue requests 5 | * to our Laravel back-end. This library automatically handles sending the 6 | * CSRF token as a header based on the value of the "XSRF" token cookie. 7 | */ 8 | 9 | window.axios = require('axios'); 10 | 11 | window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; 12 | 13 | /** 14 | * Echo exposes an expressive API for subscribing to channels and listening 15 | * for events that are broadcast by Laravel. Echo and event broadcasting 16 | * allows your team to easily build robust real-time web applications. 17 | */ 18 | 19 | // import Echo from 'laravel-echo'; 20 | 21 | // window.Pusher = require('pusher-js'); 22 | 23 | // window.Echo = new Echo({ 24 | // broadcaster: 'pusher', 25 | // key: process.env.MIX_PUSHER_APP_KEY, 26 | // cluster: process.env.MIX_PUSHER_APP_CLUSTER, 27 | // forceTLS: true 28 | // }); 29 | -------------------------------------------------------------------------------- /app/Console/Commands/TweetAboutWeeklySummaryCommand.php: -------------------------------------------------------------------------------- 1 | finished() 19 | ->fromLastWeek() 20 | ->count(); 21 | 22 | if (! $streamsCount) { 23 | $this->info('There were no streams last week.'); 24 | 25 | return self::SUCCESS; 26 | } 27 | 28 | app(Twitter::class) 29 | ->tweet("There were $streamsCount streams last week. 👏 Thanks to all the streamers and viewers. 🙏🏻\n Find them here: ".route('archive')); 30 | 31 | return self::SUCCESS; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "mix", 6 | "watch": "mix watch", 7 | "watch-poll": "mix watch -- --watch-options-poll=1000", 8 | "hot": "mix watch --hot", 9 | "prod": "npm run production", 10 | "production": "mix --production" 11 | }, 12 | "devDependencies": { 13 | "@tailwindcss/aspect-ratio": "^0.2.0", 14 | "@tailwindcss/forms": "^0.3.1", 15 | "@tailwindcss/typography": "^0.4.0", 16 | "alpinejs": "^2.8.2", 17 | "axios": "^0.21", 18 | "browser-sync": "^2.27.5", 19 | "browser-sync-webpack-plugin": "^2.3.0", 20 | "laravel-mix": "^6.0.6", 21 | "lodash": "^4.17.19", 22 | "postcss": "^8.1.14", 23 | "postcss-import": "^14.0.1", 24 | "tailwindcss": "^2.2.0" 25 | }, 26 | "dependencies": { 27 | "moment": "^2.29.1", 28 | "moment-timezone": "^0.5.33" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Actions/ImportVideoAction.php: -------------------------------------------------------------------------------- 1 | $video->videoId], [ 19 | 'channel_title' => $video->channelTitle, 20 | 'title' => $video->title, 21 | 'description' => $video->description, 22 | 'thumbnail_url' => $video->thumbnailUrl, 23 | 'scheduled_start_time' => $video->plannedStart, 24 | 'language_code' => $languageCode, 25 | 'status' => $video->status, 26 | 'approved_at' => $approved ? now() : null, 27 | 'submitted_by_email' => $submittedByEmail, 28 | ]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Feature/Actions/Submission/RejectStreamActionTest.php: -------------------------------------------------------------------------------- 1 | notApproved() 20 | ->create([ 21 | 'submitted_by_email' => 'john@example.com', 22 | ]); 23 | 24 | // Act 25 | $action = app(RejectStreamAction::class); 26 | $action->handle($stream); 27 | 28 | // Assert 29 | Mail::assertQueued(fn(StreamRejectedMail $mail) => $mail->hasTo($stream->submitted_by_email)); 30 | $this->assertFalse($stream->refresh()->isApproved()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | originalYoutubeApiKey = config()->get('services.youtube.key', 'REAL-YOUTUBE-API-KEY') ?? ''; 23 | config()->set('services.youtube.key', 'FAKE-YOUTUBE-KEY'); 24 | $this->twitterFake = new TwitterFake(); 25 | $this->app->instance(Twitter::class, $this->twitterFake); 26 | } 27 | 28 | protected function useRealYoutubeApi(): void 29 | { 30 | config()->set('services.youtube.key', $this->originalYoutubeApiKey); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /database/migrations/2019_08_19_000000_create_failed_jobs_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('uuid')->unique(); 19 | $table->text('connection'); 20 | $table->text('queue'); 21 | $table->longText('payload'); 22 | $table->longText('exception'); 23 | $table->timestamp('failed_at')->useCurrent(); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('failed_jobs'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/migrations/2021_05_13_114225_create_sessions_table.php: -------------------------------------------------------------------------------- 1 | string('id')->primary(); 18 | $table->foreignId('user_id')->nullable()->index(); 19 | $table->string('ip_address', 45)->nullable(); 20 | $table->text('user_agent')->nullable(); 21 | $table->text('payload'); 22 | $table->integer('last_activity')->index(); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | * 29 | * @return void 30 | */ 31 | public function down() 32 | { 33 | Schema::dropIfExists('sessions'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Feature/Http/Controllers/Submission/ApproveStreamControllerTest.php: -------------------------------------------------------------------------------- 1 | notApproved() 20 | ->create([ 21 | 'submitted_by_email' => 'john@example.com', 22 | ]); 23 | 24 | // Assert 25 | $this->assertFalse($stream->isApproved()); 26 | 27 | // Act 28 | $this->get($stream->approveUrl()) 29 | ->assertOk(); 30 | 31 | // Assert 32 | $this->assertTrue($stream->refresh()->isApproved()); 33 | 34 | Mail::assertQueued(StreamApprovedMail::class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/Rules/YouTubeRule.php: -------------------------------------------------------------------------------- 1 | message = 'This is not a valid YouTube video id.'; 19 | 20 | return false; 21 | } 22 | 23 | if (is_null($video)) { 24 | $this->message = 'This is not a valid YouTube video id.'; 25 | 26 | return false; 27 | } 28 | 29 | if (! $video->plannedStart->isFuture()) { 30 | $this->message = 'We only accept streams that have not started yet.'; 31 | 32 | return false; 33 | } 34 | 35 | return true; 36 | } 37 | 38 | public function message(): string 39 | { 40 | return $this->message; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/Models/Channel.php: -------------------------------------------------------------------------------- 1 | hasMany(Stream::class); 19 | } 20 | 21 | public function approvedFinishedStreams(): HasMany 22 | { 23 | return $this->hasMany(Stream::class) 24 | ->approved() 25 | ->finished(); 26 | } 27 | 28 | public function scopeAutoImportEnabled(Builder $query): Builder 29 | { 30 | return $query->where('auto_import', true); 31 | } 32 | 33 | public function url(): string 34 | { 35 | return "https://www.youtube.com/channel/{$this->platform_id}"; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/Actions/CollectTimezones.php: -------------------------------------------------------------------------------- 1 | [ 16 | 'elements' => [ 17 | 'currencies' => true, 18 | 'flag' => true, 19 | 'timezones' => true, 20 | ], 21 | ], 22 | ])); 23 | 24 | return $countries 25 | ->all() 26 | ->map(function($country) { 27 | return $country->timezones->first()->zone_name ?? null; 28 | }) 29 | ->sort() 30 | ->filter(fn($timezone) => ! is_null($timezone)) 31 | ->toArray(); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->morphs('tokenable'); 19 | $table->string('name'); 20 | $table->string('token', 64)->unique(); 21 | $table->text('abilities')->nullable(); 22 | $table->timestamp('last_used_at')->nullable(); 23 | $table->timestamps(); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('personal_access_tokens'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/Console/Commands/TweetAboutLiveStreamsCommand.php: -------------------------------------------------------------------------------- 1 | approved() 20 | ->where('status', StreamData::STATUS_LIVE) 21 | ->whereNull('tweeted_at') 22 | ->get() 23 | ->each(function(Stream $stream) { 24 | dispatch(new TweetStreamIsLiveJob($stream)); 25 | }); 26 | 27 | if ($streams->isEmpty()) { 28 | $this->info('There are no streams to tweet.'); 29 | 30 | return self::SUCCESS; 31 | } 32 | 33 | $this->info("{$streams->count()} tweets sent"); 34 | 35 | return self::SUCCESS; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/Actions/SortStreamsByDateAction.php: -------------------------------------------------------------------------------- 1 | groupBy(static fn(Stream $item): string => $item->scheduled_start_time->format('D d.m.Y')) 15 | ->mapWithKeys(static function(Collection $item, string $date): array { 16 | $dateObject = Carbon::createFromFormat('D d.m.Y', $date); 17 | 18 | $date = $dateObject->format('D, M jS Y'); 19 | 20 | if ($dateObject->isYesterday()) { 21 | $date = 'Yesterday'; 22 | } 23 | 24 | if ($dateObject->isToday()) { 25 | $date = 'Today'; 26 | } 27 | 28 | if ($dateObject->isTomorrow()) { 29 | $date = 'Tomorrow'; 30 | } 31 | 32 | return [$date => $item]; 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('name'); 19 | $table->string('email')->unique(); 20 | $table->timestamp('email_verified_at')->nullable(); 21 | $table->string('password'); 22 | $table->rememberToken(); 23 | $table->foreignId('current_team_id')->nullable(); 24 | $table->text('profile_photo_path')->nullable(); 25 | $table->timestamps(); 26 | }); 27 | } 28 | 29 | /** 30 | * Reverse the migrations. 31 | * 32 | * @return void 33 | */ 34 | public function down() 35 | { 36 | Schema::dropIfExists('users'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php: -------------------------------------------------------------------------------- 1 | text('two_factor_secret') 18 | ->after('password') 19 | ->nullable(); 20 | 21 | $table->text('two_factor_recovery_codes') 22 | ->after('two_factor_secret') 23 | ->nullable(); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::table('users', function (Blueprint $table) { 35 | $table->dropColumn('two_factor_secret', 'two_factor_recovery_codes'); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /resources/views/auth/confirm-password.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | {{ __('This is a secure area of the application. Please confirm your password before continuing.') }} 9 |
10 | 11 | 12 | 13 |
14 | @csrf 15 | 16 |
17 | 18 | 19 |
20 | 21 |
22 | 23 | {{ __('Confirm') }} 24 | 25 |
26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=Laravel 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_URL=http://localhost 6 | 7 | LOG_CHANNEL=stack 8 | LOG_LEVEL=debug 9 | 10 | DB_CONNECTION=mysql 11 | DB_HOST=127.0.0.1 12 | DB_PORT=3306 13 | DB_DATABASE=larastreamers 14 | 15 | BROADCAST_DRIVER=log 16 | CACHE_DRIVER=file 17 | QUEUE_CONNECTION=sync 18 | SESSION_DRIVER=database 19 | SESSION_LIFETIME=120 20 | 21 | MEMCACHED_HOST=127.0.0.1 22 | 23 | REDIS_HOST=127.0.0.1 24 | REDIS_PASSWORD=null 25 | REDIS_PORT=6379 26 | 27 | MAIL_MAILER=smtp 28 | MAIL_HOST=mailhog 29 | MAIL_PORT=1025 30 | MAIL_USERNAME=null 31 | MAIL_PASSWORD=null 32 | MAIL_ENCRYPTION=null 33 | MAIL_FROM_ADDRESS=null 34 | MAIL_FROM_NAME="${APP_NAME}" 35 | 36 | AWS_ACCESS_KEY_ID= 37 | AWS_SECRET_ACCESS_KEY= 38 | AWS_DEFAULT_REGION=us-east-1 39 | AWS_BUCKET= 40 | 41 | PUSHER_APP_ID= 42 | PUSHER_APP_KEY= 43 | PUSHER_APP_SECRET= 44 | PUSHER_APP_CLUSTER=mt1 45 | 46 | MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" 47 | MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" 48 | 49 | YOUTUBE_API_KEY= 50 | 51 | TWITTER_CONSUMER_KEY= 52 | TWITTER_CONSUMER_SECRET= 53 | TWITTER_ACCESS_TOKEN= 54 | TWITTER_ACCESS_TOKEN_SECRET= 55 | 56 | -------------------------------------------------------------------------------- /config/feed.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'main' => [ 6 | /* 7 | * Here you can specify which class and method will return 8 | * the items that should appear in the feed. For example: 9 | * 'App\Model@getAllFeedItems' 10 | * 11 | * You can also pass an argument to that method: 12 | * ['App\Model@getAllFeedItems', 'argument'] 13 | */ 14 | 'items' => 'App\Models\Stream@getFeedItems', 15 | 16 | /* 17 | * The feed will be available on this url. 18 | */ 19 | 'url' => '', 20 | 21 | 'title' => 'All upcoming streams', 22 | 'description' => 'All upcoming streams.', //TODO add some copy 23 | 'language' => 'en-US', 24 | 25 | /* 26 | * The view that will render the feed. 27 | */ 28 | 'view' => 'feed::atom', 29 | 30 | /* 31 | * The type to be used in the tag 32 | */ 33 | 'type' => 'application/atom+xml', 34 | ], 35 | ], 36 | ]; 37 | -------------------------------------------------------------------------------- /app/Console/Commands/TweetAboutUpcomingStreamsCommand.php: -------------------------------------------------------------------------------- 1 | approved() 19 | ->upcoming() 20 | ->withinUpcomingTweetRange() 21 | ->scheduledTimeNotPassed() 22 | ->whereNull('upcoming_tweeted_at') 23 | ->get() 24 | ->each(function(Stream $stream) { 25 | dispatch(new TweetStreamIsUpcomingJob($stream)); 26 | }); 27 | 28 | if ($streams->isEmpty()) { 29 | $this->info('There are no streams to tweet.'); 30 | 31 | return self::SUCCESS; 32 | } 33 | 34 | $this->info("{$streams->count()} tweets sent"); 35 | 36 | return self::SUCCESS; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/Jobs/TweetStreamIsLiveJob.php: -------------------------------------------------------------------------------- 1 | stream->tweetStreamIsLiveWasSend()) { 26 | return; 27 | } 28 | 29 | $twitterHandleIfGiven = Str::of(' ') 30 | ->when($twitterHandle = $this->stream->channel?->twitter_handle, fn() => " by $twitterHandle "); 31 | 32 | app(Twitter::class) 33 | ->tweet("🔴 A new stream{$twitterHandleIfGiven}just started: {$this->stream->title}".PHP_EOL.$this->stream->url()); 34 | 35 | $this->stream->markAsTweeted(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/Providers/JetstreamServiceProvider.php: -------------------------------------------------------------------------------- 1 | configurePermissions(); 29 | 30 | Jetstream::deleteUsersUsing(DeleteUser::class); 31 | } 32 | 33 | /** 34 | * Configure the permissions that are available within the application. 35 | * 36 | * @return void 37 | */ 38 | protected function configurePermissions() 39 | { 40 | Jetstream::defaultApiTokenPermissions(['read']); 41 | 42 | Jetstream::permissions([ 43 | 'create', 44 | 'read', 45 | 'update', 46 | 'delete', 47 | ]); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Feature/JetStream/AuthenticationTest.php: -------------------------------------------------------------------------------- 1 | get('/login'); 14 | 15 | $response->assertStatus(200); 16 | } 17 | 18 | public function test_users_can_authenticate_using_the_login_screen() 19 | { 20 | $user = User::factory()->create(); 21 | 22 | $response = $this->post('/login', [ 23 | 'email' => $user->email, 24 | 'password' => 'password', 25 | ]); 26 | 27 | $this->assertAuthenticated(); 28 | $response->assertRedirect(RouteServiceProvider::HOME); 29 | } 30 | 31 | public function test_users_can_not_authenticate_with_invalid_password() 32 | { 33 | $user = User::factory()->create(); 34 | 35 | $this->post('/login', [ 36 | 'email' => $user->email, 37 | 'password' => 'wrong-password', 38 | ]); 39 | 40 | $this->assertGuest(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/Actions/Fortify/CreateNewUser.php: -------------------------------------------------------------------------------- 1 | ['required', 'string', 'max:255'], 25 | 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], 26 | 'password' => $this->passwordRules(), 27 | 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['required', 'accepted'] : '', 28 | ])->validate(); 29 | 30 | return User::create([ 31 | 'name' => $input['name'], 32 | 'email' => $input['email'], 33 | 'password' => Hash::make($input['password']), 34 | ]); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /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' => env( 32 | 'VIEW_COMPILED_PATH', 33 | realpath(storage_path('framework/views')) 34 | ), 35 | 36 | ]; 37 | -------------------------------------------------------------------------------- /tests/Feature/FeedTest.php: -------------------------------------------------------------------------------- 1 | create([ 16 | 'title' => 'Stream tomorrow', 17 | 'channel_title' => 'Channel one', 18 | 'description' => 'Stream description', 19 | 'scheduled_start_time' => Carbon::tomorrow(), 20 | ]); 21 | Stream::factory()->create(['title' => 'Stream today', 'scheduled_start_time' => Carbon::today()]); 22 | Stream::factory()->create(['title' => 'Stream yesterday', 'scheduled_start_time' => Carbon::yesterday()]); 23 | 24 | // Act 25 | $response = $this->get('feed'); 26 | 27 | // Assert 28 | $response->assertSeeInOrder([ 29 | 'Stream tomorrow', 30 | 'Stream description', 31 | ]); 32 | 33 | $response->assertSeeInOrder([ 34 | 'Stream tomorrow', 35 | 'Stream today', 36 | 'Stream yesterday', 37 | ]); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Feature/JetStream/ProfileInformationTest.php: -------------------------------------------------------------------------------- 1 | actingAs($user = User::factory()->create()); 15 | 16 | $component = Livewire::test(UpdateProfileInformationForm::class); 17 | 18 | $this->assertEquals($user->name, $component->state['name']); 19 | $this->assertEquals($user->email, $component->state['email']); 20 | } 21 | 22 | public function test_profile_information_can_be_updated() 23 | { 24 | $this->actingAs($user = User::factory()->create()); 25 | 26 | Livewire::test(UpdateProfileInformationForm::class) 27 | ->set('state', ['name' => 'Test Name', 'email' => 'test@example.com']) 28 | ->call('updateProfileInformation'); 29 | 30 | $this->assertEquals('Test Name', $user->fresh()->name); 31 | $this->assertEquals('test@example.com', $user->fresh()->email); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/Http/Resources/StreamResource.php: -------------------------------------------------------------------------------- 1 | $this->id, 13 | 'type' => 'stream', 14 | 'attributes' => [ 15 | 'title' => $this->title, 16 | 'description' => $this->description, 17 | 'channel' => $this->channel_title, 18 | 'thumbnail_url' => $this->thumbnail_url, 19 | 'starts' => [ 20 | 'human' => $this->scheduled_start_time->diffForHumans(), 21 | 'string' => $this->scheduled_start_time->toDateTimeString(), 22 | 'formatted' => $this->scheduled_start_time->format('D d.m.Y'), 23 | ], 24 | 'status' => $this->status, 25 | 'language_code' => $this->language_code, 26 | 'live' => $this->isLive(), 27 | 'identifiers' => [ 28 | 'youtube' => $this->youtube_id, 29 | ], 30 | ], 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/Actions/Fortify/UpdateUserPassword.php: -------------------------------------------------------------------------------- 1 | ['required', 'string'], 24 | 'password' => $this->passwordRules(), 25 | ])->after(function($validator) use ($user, $input) { 26 | if (! isset($input['current_password']) || ! Hash::check($input['current_password'], $user->password)) { 27 | $validator->errors()->add('current_password', __('The provided password does not match your current password.')); 28 | } 29 | })->validateWithBag('updatePassword'); 30 | 31 | $user->forceFill([ 32 | 'password' => Hash::make($input['password']), 33 | ])->save(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Feature/JetStream/DeleteApiTokenTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('API support is not enabled.'); 18 | } 19 | 20 | if (Features::hasTeamFeatures()) { 21 | $this->actingAs($user = User::factory()->withPersonalTeam()->create()); 22 | } else { 23 | $this->actingAs($user = User::factory()->create()); 24 | } 25 | 26 | $token = $user->tokens()->create([ 27 | 'name' => 'Test Token', 28 | 'token' => Str::random(40), 29 | 'abilities' => ['create', 'read'], 30 | ]); 31 | 32 | Livewire::test(ApiTokenManager::class) 33 | ->set(['apiTokenIdBeingDeleted' => $token->id]) 34 | ->call('deleteApiToken'); 35 | 36 | $this->assertCount(0, $user->fresh()->tokens); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /resources/views/pages/partials/footer.blade.php: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /app/Http/Controllers/CalendarController.php: -------------------------------------------------------------------------------- 1 | name('Larastreamers') 17 | ->description('There is no better way to learn than by watching other developers code live. Find out who is streaming next in the Laravel world.') 18 | ->refreshInterval(CarbonInterval::hour()->totalMinutes) 19 | ->productIdentifier('larastreamers.com'); 20 | 21 | Stream::query() 22 | ->when( 23 | $request->get('languages'), 24 | fn($query, $languages) => $query->whereIn('language_code', explode(',', $languages)) 25 | ) 26 | ->notOlderThanAYear() 27 | ->each(fn(Stream $stream) => $calendar->event( 28 | $stream->toCalendarItem() 29 | )); 30 | 31 | return response($calendar->get()) 32 | ->header('Content-Type', 'text/calendar'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /public/web.config: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | pull_request: 8 | types: [ready_for_review, synchronize, opened] 9 | 10 | jobs: 11 | run-tests: 12 | name: Run tests 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | 18 | - name: Setup PHP, with composer and extensions 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: '8.0' 22 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 23 | tools: composer:v2 24 | coverage: none 25 | 26 | - name: Get composer cache directory 27 | id: composer-cache 28 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 29 | 30 | - name: Cache composer dependencies 31 | uses: actions/cache@v2 32 | with: 33 | path: ${{ steps.composer-cache.outputs.dir }} 34 | key: composer-${{ hashFiles('composer.json') }} 35 | restore-keys: composer- 36 | 37 | - name: Run composer install 38 | run: composer update --prefer-dist --no-interaction --no-suggest 39 | 40 | - name: Run tests 41 | run: ./vendor/bin/phpunit 42 | -------------------------------------------------------------------------------- /database/seeders/TestDataSeeder.php: -------------------------------------------------------------------------------- 1 | 'Christoph', 22 | 'email' => 'test@test.at', 23 | 'password' => bcrypt('test'), 24 | ]); 25 | } 26 | 27 | Channel::truncate(); 28 | Stream::truncate(); 29 | 30 | $channels = Channel::factory()->count(4)->create(); 31 | 32 | Stream::factory() 33 | ->approved() 34 | ->live() 35 | ->for($channels->random()) 36 | ->count(3) 37 | ->create(); 38 | 39 | Stream::factory() 40 | ->approved() 41 | ->upcoming() 42 | ->for($channels->random()) 43 | ->count(100) 44 | ->create(); 45 | 46 | Stream::factory() 47 | ->finished() 48 | ->for($channels->random()) 49 | ->count(100) 50 | ->create(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Feature/Commands/CheckIfLiveStreamsHaveEndedCommandTest.php: -------------------------------------------------------------------------------- 1 | live()->create(); 20 | 21 | // Act & Expect 22 | $this->artisan(CheckIfLiveStreamsHaveEndedCommand::class) 23 | ->expectsOutput('Fetching 1 stream(s) from API to update their status.') 24 | ->assertExitCode(0); 25 | } 26 | 27 | /** @test */ 28 | public function it_does_not_update_finished_or_upcoming_streams(): void 29 | { 30 | // Arrange 31 | Http::fake(); 32 | 33 | // Arrange 34 | Stream::factory()->upcoming()->create(); 35 | Stream::factory()->finished()->create(); 36 | 37 | // Act & Expect 38 | $this->artisan(CheckIfLiveStreamsHaveEndedCommand::class) 39 | ->expectsOutput('There are no streams to update.') 40 | ->assertExitCode(0); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/Jobs/TweetStreamIsUpcomingJob.php: -------------------------------------------------------------------------------- 1 | stream->tweetStreamIsUpcomingWasSend()) { 26 | return; 27 | } 28 | 29 | if ($this->stream->tweetStreamIsLiveWasSend()) { 30 | return; 31 | } 32 | 33 | $twitterHandleIfGiven = Str::of(' ') 34 | ->when($twitterHandle = $this->stream->channel?->twitter_handle, fn() => " by $twitterHandle "); 35 | 36 | app(Twitter::class) 37 | ->tweet("🔴 A new stream{$twitterHandleIfGiven}is about to start: {$this->stream->title}. Join now!".PHP_EOL.$this->stream->url()); 38 | 39 | $this->stream->update([ 40 | 'upcoming_tweeted_at' => now(), 41 | ]); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Feature/JetStream/PasswordConfirmationTest.php: -------------------------------------------------------------------------------- 1 | withPersonalTeam()->create() 15 | : User::factory()->create(); 16 | 17 | $response = $this->actingAs($user)->get('/user/confirm-password'); 18 | 19 | $response->assertStatus(200); 20 | } 21 | 22 | public function test_password_can_be_confirmed() 23 | { 24 | $user = User::factory()->create(); 25 | 26 | $response = $this->actingAs($user)->post('/user/confirm-password', [ 27 | 'password' => 'password', 28 | ]); 29 | 30 | $response->assertRedirect(); 31 | $response->assertSessionHasNoErrors(); 32 | } 33 | 34 | public function test_password_is_not_confirmed_with_invalid_password() 35 | { 36 | $user = User::factory()->create(); 37 | 38 | $response = $this->actingAs($user)->post('/user/confirm-password', [ 39 | 'password' => 'wrong-password', 40 | ]); 41 | 42 | $response->assertSessionHasErrors(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /resources/views/auth/forgot-password.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | {{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }} 9 |
10 | 11 | @if (session('status')) 12 |
13 | {{ session('status') }} 14 |
15 | @endif 16 | 17 | 18 | 19 |
20 | @csrf 21 | 22 |
23 | 24 | 25 |
26 | 27 |
28 | 29 | {{ __('Email Password Reset Link') }} 30 | 31 |
32 |
33 |
34 |
35 | -------------------------------------------------------------------------------- /resources/views/livewire/timezone-switcher.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 11 |
12 | 13 | 19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests/Unit 10 | 11 | 12 | ./tests/Feature 13 | 14 | 15 | 16 | 17 | ./app 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/Console/Commands/CheckIfLiveStreamsHaveEndedCommand.php: -------------------------------------------------------------------------------- 1 | live() 20 | ->get() 21 | ->keyBy('youtube_id'); 22 | 23 | if ($streams->isEmpty()) { 24 | $this->info('There are no streams to update.'); 25 | 26 | return self::SUCCESS; 27 | } 28 | 29 | $this->info("Fetching {$streams->count()} stream(s) from API to update their status."); 30 | 31 | $updatesCount = YouTube::videos($streams->keys()) 32 | ->map(fn(StreamData $streamData) => optional($streams 33 | ->get($streamData->videoId)) 34 | ->update([ 35 | 'status' => $streamData->status, 36 | ])) 37 | ->filter() 38 | ->count(); 39 | 40 | $this->info($updatesCount.' stream(s) were updated.'); 41 | 42 | return self::SUCCESS; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /database/factories/ChannelFactory.php: -------------------------------------------------------------------------------- 1 | 'youtube', 17 | 'platform_id' => $this->faker->word, 18 | 'language_code' => 'en', 19 | 'slug' => $this->faker->slug, 20 | 'name' => collect(['Dr Disrespect', 'Lulu', 'Harris Heller', 'itzTimmy'])->random(), 21 | 'description' => $this->faker->text, 22 | 'thumbnail_url' => collect([ 23 | 'https://yt3.ggpht.com/JWtY_fLVUhRudr1y1TqOH60PGjmUjAjW3vKHIBrvge4j_Czh1XEkLekqEACaJEJdWkG00HeOsg=s176-c-k-c0x00ffffff-no-rj', 24 | 'https://yt3.ggpht.com/ytc/AKedOLRLFKZcTc_hXy75Y829rvkXzIAGxKftFRqt222Z7i4=s176-c-k-c0x00ffffff-no-rj', 25 | ])->random(), 26 | 'country' => $this->faker->countryCode, 27 | 'twitter_handle' => $this->faker->word, 28 | 'on_platform_since' => Carbon::now(), 29 | ]; 30 | } 31 | 32 | public function autoImportEnabled(): self 33 | { 34 | return $this->state(function() { 35 | return ['auto_import' => true]; 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/Http/Livewire/ImportYouTubeChannel.php: -------------------------------------------------------------------------------- 1 | youTubeChannelId); 31 | } catch (YouTubeException $exception) { 32 | $this->addError('channel', $exception->getMessage()); 33 | 34 | return; 35 | } 36 | Channel::updateOrCreate(array_merge($channelData->prepareForModel(), ['language_code' => $this->languageCode])); 37 | 38 | dispatch(new ImportYoutubeChannelStreamsJob($this->youTubeChannelId, $this->languageCode)); 39 | 40 | session()->flash('channel-message', 'Channel "'.$this->youTubeChannelId.'" was added successfully.'); 41 | 42 | $this->reset(['youTubeChannelId', 'languageCode']); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /resources/views/components/main-layout.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | {{ $title }} 12 | 13 | 15 | 16 | 18 | 19 | @include('pages.partials.meta') 20 | 21 | 23 | 24 | @include('feed::links') 25 | 26 | @livewireStyles 27 | 28 | 29 | @production 30 | 31 | @endproduction 32 | 33 | 34 | 35 | 36 | 37 | 38 | {{ $slot ?? '' }} 39 | 40 | @include('pages.partials.footer') 41 | 42 | @include('pages.partials.submit-modal') 43 | 44 | @livewireScripts 45 | 46 | 47 | @stack('scripts') 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'domain' => env('MAILGUN_DOMAIN'), 19 | 'secret' => env('MAILGUN_SECRET'), 20 | 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), 21 | ], 22 | 23 | 'twitter' => [ 24 | 'consumer_key' => env('TWITTER_CONSUMER_KEY'), 25 | 'consumer_secret' => env('TWITTER_CONSUMER_SECRET'), 26 | 'access_token' => env('TWITTER_ACCESS_TOKEN'), 27 | 'access_token_secret' => env('TWITTER_ACCESS_TOKEN_SECRET'), 28 | ], 29 | 30 | 'youtube' => [ 31 | 'key' => env('YOUTUBE_API_KEY'), 32 | ], 33 | 34 | 'postmark' => [ 35 | 'token' => env('POSTMARK_TOKEN'), 36 | ], 37 | 38 | 'ses' => [ 39 | 'key' => env('AWS_ACCESS_KEY_ID'), 40 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 41 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 42 | ], 43 | 44 | ]; 45 | -------------------------------------------------------------------------------- /app/Models/User.php: -------------------------------------------------------------------------------- 1 | 'datetime', 50 | ]; 51 | 52 | /** 53 | * The accessors to append to the model's array form. 54 | * 55 | * @var array 56 | */ 57 | protected $appends = [ 58 | 'profile_photo_url', 59 | ]; 60 | } 61 | -------------------------------------------------------------------------------- /tests/Feature/JetStream/DeleteAccountTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Account deletion is not enabled.'); 17 | } 18 | 19 | $this->actingAs($user = User::factory()->create()); 20 | 21 | $component = Livewire::test(DeleteUserForm::class) 22 | ->set('password', 'password') 23 | ->call('deleteUser'); 24 | 25 | $this->assertNull($user->fresh()); 26 | } 27 | 28 | public function test_correct_password_must_be_provided_before_account_can_be_deleted() 29 | { 30 | if (! Features::hasAccountDeletionFeatures()) { 31 | return $this->markTestSkipped('Account deletion is not enabled.'); 32 | } 33 | 34 | $this->actingAs($user = User::factory()->create()); 35 | 36 | Livewire::test(DeleteUserForm::class) 37 | ->set('password', 'wrong-password') 38 | ->call('deleteUser') 39 | ->assertHasErrors(['password']); 40 | 41 | $this->assertNotNull($user->fresh()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Feature/JetStream/CreateApiTokenTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('API support is not enabled.'); 17 | } 18 | 19 | if (Features::hasTeamFeatures()) { 20 | $this->actingAs($user = User::factory()->withPersonalTeam()->create()); 21 | } else { 22 | $this->actingAs($user = User::factory()->create()); 23 | } 24 | 25 | Livewire::test(ApiTokenManager::class) 26 | ->set(['createApiTokenForm' => [ 27 | 'name' => 'Test Token', 28 | 'permissions' => [ 29 | 'read', 30 | 'update', 31 | ], 32 | ]]) 33 | ->call('createApiToken'); 34 | 35 | $this->assertCount(1, $user->fresh()->tokens); 36 | $this->assertEquals('Test Token', $user->fresh()->tokens->first()->name); 37 | $this->assertTrue($user->fresh()->tokens->first()->can('read')); 38 | $this->assertFalse($user->fresh()->tokens->first()->can('delete')); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Feature/Actions/Submission/SubmitStreamActionTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive('videos') 24 | ->andReturn(collect([ 25 | StreamData::fake( 26 | videoId: $this->youTubeId, 27 | ), 28 | ])); 29 | 30 | // Act 31 | $action = app(SubmitStreamAction::class); 32 | $action->handle($this->youTubeId, 'de', 'john@example.com'); 33 | 34 | // Assert 35 | $stream = Stream::firstWhere('youtube_id', $this->youTubeId); 36 | $this->assertNotNull($stream); 37 | 38 | $this->assertFalse($stream->isApproved()); 39 | $this->assertEquals('john@example.com', $stream->submitted_by_email); 40 | $this->assertEquals('de', $stream->language_code); 41 | 42 | Mail::assertQueued(fn(StreamSubmittedMail $mail) => $mail->hasTo('christoph@christoph-rumpel.com')); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /resources/views/layouts/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ config('app.name', 'Laravel') }} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | @livewireStyles 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | @livewire('navigation-menu') 26 | 27 | 28 | @if (isset($header)) 29 |
30 |
31 | {{ $header }} 32 |
33 |
34 | @endif 35 | 36 | 37 |
38 | {{ $slot }} 39 |
40 |
41 | 42 | @stack('modals') 43 | 44 | @livewireScripts 45 | 46 | 47 | -------------------------------------------------------------------------------- /app/Providers/FortifyServiceProvider.php: -------------------------------------------------------------------------------- 1 | by($request->email.$request->ip()); 41 | }); 42 | 43 | RateLimiter::for('two-factor', function(Request $request) { 44 | return Limit::perMinute(5)->by($request->session()->get('login.id')); 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/Console/Commands/CheckIfUpcomingStreamsAreLiveCommand.php: -------------------------------------------------------------------------------- 1 | approved() 20 | ->upcoming() 21 | ->where('scheduled_start_time', '<=', now()->addMinutes(15)) 22 | ->get() 23 | ->keyBy('youtube_id'); 24 | 25 | if ($streams->isEmpty()) { 26 | $this->info('There are no streams to update.'); 27 | 28 | return self::SUCCESS; 29 | } 30 | 31 | $this->info("Fetching {$streams->count()} stream(s) from API to update their status."); 32 | 33 | $updatesCount = YouTube::videos($streams->keys()) 34 | ->map(fn(StreamData $streamData) => optional($streams 35 | ->get($streamData->videoId)) 36 | ->update([ 37 | 'status' => $streamData->status, 38 | ])) 39 | ->filter() 40 | ->count(); 41 | 42 | $this->info($updatesCount.' stream(s) were updated.'); 43 | 44 | return self::SUCCESS; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /resources/views/auth/verify-email.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | {{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }} 9 |
10 | 11 | @if (session('status') == 'verification-link-sent') 12 |
13 | {{ __('A new verification link has been sent to the email address you provided during registration.') }} 14 |
15 | @endif 16 | 17 |
18 |
19 | @csrf 20 | 21 |
22 | 23 | {{ __('Resend Verification Email') }} 24 | 25 |
26 |
27 | 28 |
29 | @csrf 30 | 31 | 34 |
35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /tests/Feature/Actions/Submission/ApproveStreamActionTest.php: -------------------------------------------------------------------------------- 1 | approveStream = app(ApproveStreamAction::class); 22 | } 23 | 24 | /** @test */ 25 | public function the_action_can_approve_a_stream(): void 26 | { 27 | $stream = Stream::factory() 28 | ->notApproved() 29 | ->create([ 30 | 'submitted_by_email' => 'john@example.com', 31 | ]); 32 | 33 | $this->approveStream->handle($stream); 34 | 35 | $stream = $stream->fresh(); 36 | 37 | $this->assertNotNull($stream->approved_at); 38 | 39 | Mail::assertQueued(fn(StreamApprovedMail $mail) => $mail->hasTo($stream->submitted_by_email)); 40 | } 41 | 42 | /** @test */ 43 | public function it_will_not_send_a_mail_for_a_link_that_was_already_approved(): void 44 | { 45 | $stream = Stream::factory() 46 | ->approved() 47 | ->create([ 48 | 'submitted_by_email' => 'john@example.com', 49 | ]); 50 | 51 | $this->approveStream->handle($stream); 52 | 53 | Mail::assertNothingQueued(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /resources/views/auth/reset-password.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | @csrf 11 | 12 | 13 | 14 |
15 | 16 | 17 |
18 | 19 |
20 | 21 | 22 |
23 | 24 |
25 | 26 | 27 |
28 | 29 |
30 | 31 | {{ __('Reset Password') }} 32 | 33 |
34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /resources/views/profile/show.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | {{ __('Profile') }} 5 |

6 |
7 | 8 |
9 |
10 | @if (Laravel\Fortify\Features::canUpdateProfileInformation()) 11 | @livewire('profile.update-profile-information-form') 12 | 13 | 14 | @endif 15 | 16 | @if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::updatePasswords())) 17 |
18 | @livewire('profile.update-password-form') 19 |
20 | 21 | 22 | @endif 23 | 24 | @if (Laravel\Fortify\Features::canManageTwoFactorAuthentication()) 25 |
26 | @livewire('profile.two-factor-authentication-form') 27 |
28 | 29 | 30 | @endif 31 | 32 |
33 | @livewire('profile.logout-other-browser-sessions-form') 34 |
35 | 36 | @if (Laravel\Jetstream\Jetstream::hasAccountDeletionFeatures()) 37 | 38 | 39 |
40 | @livewire('profile.delete-user-form') 41 |
42 | @endif 43 |
44 |
45 |
46 | -------------------------------------------------------------------------------- /app/Jobs/ImportYoutubeChannelStreamsJob.php: -------------------------------------------------------------------------------- 1 | youTubeChannelId); 26 | 27 | $streams->map(function(StreamData $streamData) { 28 | Stream::updateOrCreate(['youtube_id' => $streamData->videoId], [ 29 | 'channel_id' => optional(Channel::where('platform_id', $streamData->channelId)->first())->id, 30 | 'youtube_id' => $streamData->videoId, 31 | 'title' => $streamData->title, 32 | 'description' => $streamData->description, 33 | 'channel_title' => $streamData->channelTitle, 34 | 'thumbnail_url' => $streamData->thumbnailUrl, 35 | 'scheduled_start_time' => $streamData->plannedStart, 36 | 'language_code' => $this->languageCode, 37 | 'status' => $streamData->status, 38 | 'approved_at' => now(), 39 | ]); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /resources/views/livewire/stream-list.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | @forelse ($streamsByDate as $date => $streams) 4 |
5 |
7 |
8 |

9 | {{ $date }} 10 |

11 |
12 |
13 | 14 |
15 |
    16 | @foreach ($streams as $stream) 17 | @include('pages.partials.stream') 18 | @endforeach 19 |
20 |
21 |
22 | @empty 23 | @include('pages.partials.empty-stream-list') 24 | @endforelse 25 | 26 |
27 | {{ $streamsByDate->links() }} 28 |
29 |
30 | 31 | @push('scripts') 32 | 40 | @endpush 41 | -------------------------------------------------------------------------------- /tests/Feature/JetStream/ApiTokenPermissionsTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('API support is not enabled.'); 18 | } 19 | 20 | if (Features::hasTeamFeatures()) { 21 | $this->actingAs($user = User::factory()->withPersonalTeam()->create()); 22 | } else { 23 | $this->actingAs($user = User::factory()->create()); 24 | } 25 | 26 | $token = $user->tokens()->create([ 27 | 'name' => 'Test Token', 28 | 'token' => Str::random(40), 29 | 'abilities' => ['create', 'read'], 30 | ]); 31 | 32 | Livewire::test(ApiTokenManager::class) 33 | ->set(['managingPermissionsFor' => $token]) 34 | ->set(['updateApiTokenForm' => [ 35 | 'permissions' => [ 36 | 'delete', 37 | 'missing-permission', 38 | ], 39 | ]]) 40 | ->call('updateApiToken'); 41 | 42 | $this->assertTrue($user->fresh()->tokens->first()->can('delete')); 43 | $this->assertFalse($user->fresh()->tokens->first()->can('read')); 44 | $this->assertFalse($user->fresh()->tokens->first()->can('missing-permission')); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme'); 2 | 3 | module.exports = { 4 | purge: [ 5 | './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', 6 | './vendor/laravel/jetstream/**/*.blade.php', 7 | './storage/framework/views/*.php', 8 | './resources/views/**/*.blade.php', 9 | ], 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | sans: ['Nunito', ...defaultTheme.fontFamily.sans], 14 | }, 15 | }, 16 | colors: { 17 | white: { 18 | DEFAULT: '#F8F8F8', 19 | }, 20 | red: { 21 | dark: '#c42e47', 22 | DEFAULT: '#ff3e5e', 23 | }, 24 | green: { 25 | DEFAULT: '#69db9e', 26 | }, 27 | gray: { 28 | darkest: '#2d303e', 29 | darker: '#3f4457', 30 | dark: '#696e80', 31 | DEFAULT: '#979caf', 32 | light: '#b5b8c7', 33 | lighter: '#dee0ea', 34 | lightest: '#f7f8fc', 35 | }, 36 | black: { 37 | DEFAULT: '#212430' 38 | } 39 | } 40 | 41 | }, 42 | 43 | variants: { 44 | extend: { 45 | opacity: ['disabled'], 46 | animation: ['hover', 'group-hover'], 47 | translate: ['group-hover'], 48 | responsive: ['group-hover'] 49 | }, 50 | }, 51 | 52 | plugins: [ 53 | require('@tailwindcss/forms'), 54 | require('@tailwindcss/typography'), 55 | require('@tailwindcss/aspect-ratio'), 56 | ], 57 | }; 58 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | name('home'); 26 | 27 | Route::view('/archive', 'pages.archive') 28 | ->name('archive'); 29 | 30 | Route::get('/streamers', PageStreamersController::class) 31 | ->name('streamers'); 32 | 33 | Route::get('/calendar.ics', CalendarController::class) 34 | ->name('calendar.ics'); 35 | 36 | Route::get('/stream-{stream}.ics', AddSingleStreamToCalendarController::class) 37 | ->name('calendar.ics.stream'); 38 | 39 | Route::middleware(['auth:sanctum', 'verified'])->group(function() { 40 | Route::view('/dashboard', 'dashboard')->name('dashboard'); 41 | }); 42 | 43 | Route::middleware('signed')->group(function() { 44 | Route::get('submission/{stream}/approve', ApproveStreamController::class)->name('stream.approve'); 45 | Route::get('submission/{stream}/reject', RejectStreamController::class)->name('stream.reject'); 46 | }); 47 | -------------------------------------------------------------------------------- /app/Services/YouTube/StreamData.php: -------------------------------------------------------------------------------- 1 | status === self::STATUS_LIVE; 30 | } 31 | 32 | public static function fake(...$args): self 33 | { 34 | if (is_array($args[0] ?? null)) { 35 | $args = $args[0]; 36 | } 37 | 38 | return new static( 39 | array_merge([ 40 | 'title' => 'My Test Stream', 41 | 'channelId' => '1234', 42 | 'channelTitle' => 'My Channel Name', 43 | 'description' => 'Some description', 44 | 'thumbnailUrl' => 'my-new-thumbnail-url', 45 | 'publishedAt' => Carbon::tomorrow(), 46 | 'plannedStart' => Carbon::tomorrow(), 47 | 'actualStartTime' => Carbon::tomorrow(), 48 | 'actualEndTime' => Carbon::tomorrow()->addHour(), 49 | 'status' => static::STATUS_UPCOMING, 50 | ], $args) 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Feature/Http/Controllers/Api/V1/Streams/IndexControllerTest.php: -------------------------------------------------------------------------------- 1 | create([ 14 | 'title' => 'Stream #1', 15 | 'youtube_id' => '1234', 16 | ]); 17 | 18 | $response = $this->json( 19 | method: 'GET', 20 | uri: '/api/v1/streams', 21 | ); 22 | 23 | $response->assertStatus( 24 | status: 200, 25 | )->assertHeader( 26 | headerName: 'Content-Type', 27 | value: 'application/vnd.api+json', 28 | )->assertJsonPath( 29 | path: '0.id', 30 | expect: $stream->id, 31 | )->assertJsonPath( 32 | path: '0.attributes.identifiers.youtube', 33 | expect: $stream->youtube_id, 34 | )->assertJsonPath( 35 | path: '0.type', 36 | expect: 'stream', 37 | )->assertJsonPath( 38 | path: '0.attributes.title', 39 | expect: $stream->title, 40 | ); 41 | } 42 | 43 | /** @test */ 44 | public function it_only_shows_approved_streams(): void 45 | { 46 | $stream = Stream::factory()->approved()->create(); 47 | $stream2 = Stream::factory()->notApproved()->create(); 48 | 49 | $response = $this->json( 50 | method: 'GET', 51 | uri: '/api/v1/streams', 52 | ); 53 | 54 | $this->assertCount( 55 | expectedCount: 1, 56 | haystack: $response->json(), 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /config/hashing.php: -------------------------------------------------------------------------------- 1 | 'bcrypt', 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Bcrypt Options 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may specify the configuration options that should be used when 26 | | passwords are hashed using the Bcrypt algorithm. This will allow you 27 | | to control the amount of time it takes to hash the given password. 28 | | 29 | */ 30 | 31 | 'bcrypt' => [ 32 | 'rounds' => env('BCRYPT_ROUNDS', 10), 33 | ], 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | Argon Options 38 | |-------------------------------------------------------------------------- 39 | | 40 | | Here you may specify the configuration options that should be used when 41 | | passwords are hashed using the Argon algorithm. These will allow you 42 | | to control the amount of time it takes to hash the given password. 43 | | 44 | */ 45 | 46 | 'argon' => [ 47 | 'memory' => 1024, 48 | 'threads' => 2, 49 | 'time' => 2, 50 | ], 51 | 52 | ]; 53 | -------------------------------------------------------------------------------- /resources/lang/vendor/backup/zh-CN/notifications.php: -------------------------------------------------------------------------------- 1 | '异常信息: :message', 5 | 'exception_trace' => '异常跟踪: :trace', 6 | 'exception_message_title' => '异常信息', 7 | 'exception_trace_title' => '异常跟踪', 8 | 9 | 'backup_failed_subject' => ':application_name 备份失败', 10 | 'backup_failed_body' => '重要说明:备份 :application_name 时发生错误', 11 | 12 | 'backup_successful_subject' => ':application_name 备份成功', 13 | 'backup_successful_subject_title' => '备份成功!', 14 | 'backup_successful_body' => '好消息, :application_name 备份成功,位于磁盘 :disk_name 中。', 15 | 16 | 'cleanup_failed_subject' => '清除 :application_name 的备份失败。', 17 | 'cleanup_failed_body' => '清除备份 :application_name 时发生错误', 18 | 19 | 'cleanup_successful_subject' => '成功清除 :application_name 的备份', 20 | 'cleanup_successful_subject_title' => '成功清除备份!', 21 | 'cleanup_successful_body' => '成功清除 :disk_name 磁盘上 :application_name 的备份。', 22 | 23 | 'healthy_backup_found_subject' => ':disk_name 磁盘上 :application_name 的备份是健康的', 24 | 'healthy_backup_found_subject_title' => ':application_name 的备份是健康的', 25 | 'healthy_backup_found_body' => ':application_name 的备份是健康的。干的好!', 26 | 27 | 'unhealthy_backup_found_subject' => '重要说明::application_name 的备份不健康', 28 | 'unhealthy_backup_found_subject_title' => '重要说明::application_name 备份不健康。 :problem', 29 | 'unhealthy_backup_found_body' => ':disk_name 磁盘上 :application_name 的备份不健康。', 30 | 'unhealthy_backup_found_not_reachable' => '无法访问备份目标。 :error', 31 | 'unhealthy_backup_found_empty' => '根本没有此应用程序的备份。', 32 | 'unhealthy_backup_found_old' => '最近的备份创建于 :date ,太旧了。', 33 | 'unhealthy_backup_found_unknown' => '对不起,确切原因无法确定。', 34 | 'unhealthy_backup_found_full' => '备份占用了太多存储空间。当前占用了 :disk_usage ,高于允许的限制 :disk_limit。', 35 | ]; 36 | -------------------------------------------------------------------------------- /resources/lang/vendor/backup/zh-TW/notifications.php: -------------------------------------------------------------------------------- 1 | '異常訊息: :message', 5 | 'exception_trace' => '異常追蹤: :trace', 6 | 'exception_message_title' => '異常訊息', 7 | 'exception_trace_title' => '異常追蹤', 8 | 9 | 'backup_failed_subject' => ':application_name 備份失敗', 10 | 'backup_failed_body' => '重要說明:備份 :application_name 時發生錯誤', 11 | 12 | 'backup_successful_subject' => ':application_name 備份成功', 13 | 'backup_successful_subject_title' => '備份成功!', 14 | 'backup_successful_body' => '好消息, :application_name 備份成功,位於磁盤 :disk_name 中。', 15 | 16 | 'cleanup_failed_subject' => '清除 :application_name 的備份失敗。', 17 | 'cleanup_failed_body' => '清除備份 :application_name 時發生錯誤', 18 | 19 | 'cleanup_successful_subject' => '成功清除 :application_name 的備份', 20 | 'cleanup_successful_subject_title' => '成功清除備份!', 21 | 'cleanup_successful_body' => '成功清除 :disk_name 磁盤上 :application_name 的備份。', 22 | 23 | 'healthy_backup_found_subject' => ':disk_name 磁盤上 :application_name 的備份是健康的', 24 | 'healthy_backup_found_subject_title' => ':application_name 的備份是健康的', 25 | 'healthy_backup_found_body' => ':application_name 的備份是健康的。幹的好!', 26 | 27 | 'unhealthy_backup_found_subject' => '重要說明::application_name 的備份不健康', 28 | 'unhealthy_backup_found_subject_title' => '重要說明::application_name 備份不健康。 :problem', 29 | 'unhealthy_backup_found_body' => ':disk_name 磁盤上 :application_name 的備份不健康。', 30 | 'unhealthy_backup_found_not_reachable' => '無法訪問備份目標。 :error', 31 | 'unhealthy_backup_found_empty' => '根本沒有此應用程序的備份。', 32 | 'unhealthy_backup_found_old' => '最近的備份創建於 :date ,太舊了。', 33 | 'unhealthy_backup_found_unknown' => '對不起,確切原因無法確定。', 34 | 'unhealthy_backup_found_full' => '備份佔用了太多存儲空間。當前佔用了 :disk_usage ,高於允許的限制 :disk_limit。', 35 | ]; 36 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->name, 19 | 'email' => $this->faker->unique()->safeEmail, 20 | 'email_verified_at' => now(), 21 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 22 | 'remember_token' => Str::random(10), 23 | ]; 24 | } 25 | 26 | /** 27 | * Indicate that the model's email address should be unverified. 28 | * 29 | * @return \Illuminate\Database\Eloquent\Factories\Factory 30 | */ 31 | public function unverified() 32 | { 33 | return $this->state(function(array $attributes) { 34 | return [ 35 | 'email_verified_at' => null, 36 | ]; 37 | }); 38 | } 39 | 40 | /** 41 | * Indicate that the user should have a personal team. 42 | * 43 | * @return $this 44 | */ 45 | public function withPersonalTeam() 46 | { 47 | if (! Features::hasTeamFeatures()) { 48 | return $this->state([]); 49 | } 50 | 51 | return $this->has( 52 | Team::factory() 53 | ->state(function(array $attributes, User $user) { 54 | return ['name' => $user->name.'\'s Team', 'user_id' => $user->id, 'personal_team' => true]; 55 | }), 56 | 'ownedTeams' 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /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/Feature/Models/StreamTest.php: -------------------------------------------------------------------------------- 1 | notApproved()->create(); 16 | Stream::factory()->approved()->create(); 17 | 18 | // Act 19 | $streams = Stream::approved()->get(); 20 | 21 | // Assert 22 | $this->assertCount(1, $streams); 23 | } 24 | 25 | /** @test */ 26 | public function it_gets_next_upcoming_stream(): void 27 | { 28 | // Arrange 29 | Stream::factory() 30 | ->upcoming() 31 | ->create(['scheduled_start_time' => Carbon::tomorrow()->addDay()]); 32 | 33 | $expectedStream = Stream::factory() 34 | ->upcoming() 35 | ->create(['scheduled_start_time' => Carbon::tomorrow()]); 36 | 37 | // Act 38 | $actualStream = Stream::getNextUpcomingOrLive(); 39 | 40 | // Assert 41 | $this->assertEquals($expectedStream->id, $actualStream->id); 42 | } 43 | 44 | /** @test */ 45 | public function it_gets_next_live_stream_before_upcoming(): void 46 | { 47 | // Arrange 48 | Stream::factory() 49 | ->upcoming() 50 | ->create(['scheduled_start_time' => Carbon::tomorrow()->addDay()]); 51 | 52 | $expectedStream = Stream::factory() 53 | ->live() 54 | ->create(['scheduled_start_time' => Carbon::tomorrow()]); 55 | 56 | // Act 57 | $actualStream = Stream::getNextUpcomingOrLive(); 58 | 59 | // Assert 60 | $this->assertEquals($expectedStream->id, $actualStream->id); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /resources/views/profile/update-password-form.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ __('Update Password') }} 4 | 5 | 6 | 7 | {{ __('Ensure your account is using a long, random password to stay secure.') }} 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 |
16 | 17 |
18 | 19 | 20 | 21 |
22 | 23 |
24 | 25 | 26 | 27 |
28 |
29 | 30 | 31 | 32 | {{ __('Saved.') }} 33 | 34 | 35 | 36 | {{ __('Save') }} 37 | 38 | 39 |
40 | -------------------------------------------------------------------------------- /resources/views/components/streamer-channel.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | YouTube channel image from {{ $channel->name }} 5 |
6 |
7 |
8 |

{{ $channel->name }}

9 |
10 | 11 |

{{ $channel->country }}

12 |
13 |

{{ \Illuminate\Support\Str::of($channel->description)->limit(100) }}

14 |
15 |
16 | Show {{ $channel->approved_finished_streams_count }} 18 | streams 19 |
20 | 21 | 23 | 24 | @if($channel->twitter_handle) 25 | 26 | 28 | 29 | @endif 30 |
31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | make(Illuminate\Contracts\Console\Kernel::class); 34 | 35 | $status = $kernel->handle( 36 | $input = new Symfony\Component\Console\Input\ArgvInput, 37 | new Symfony\Component\Console\Output\ConsoleOutput 38 | ); 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Shutdown The Application 43 | |-------------------------------------------------------------------------- 44 | | 45 | | Once Artisan has finished running, we will fire off the shutdown events 46 | | so that any final work may be done by the application before we shut 47 | | down the process. This is the last thing to happen to the request. 48 | | 49 | */ 50 | 51 | $kernel->terminate($input, $status); 52 | 53 | exit($status); 54 | -------------------------------------------------------------------------------- /config/sanctum.php: -------------------------------------------------------------------------------- 1 | explode(',', env( 17 | 'SANCTUM_STATEFUL_DOMAINS', 18 | 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1,'.parse_url(env('APP_URL'), PHP_URL_HOST) 19 | )), 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Expiration Minutes 24 | |-------------------------------------------------------------------------- 25 | | 26 | | This value controls the number of minutes until an issued token will be 27 | | considered expired. If this value is null, personal access tokens do 28 | | not expire. This won't tweak the lifetime of first-party sessions. 29 | | 30 | */ 31 | 32 | 'expiration' => null, 33 | 34 | /* 35 | |-------------------------------------------------------------------------- 36 | | Sanctum Middleware 37 | |-------------------------------------------------------------------------- 38 | | 39 | | When authenticating your first-party SPA with Sanctum you may need to 40 | | customize some of the middleware Sanctum uses while processing the 41 | | request. You may change the middleware listed below as required. 42 | | 43 | */ 44 | 45 | 'middleware' => [ 46 | 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class, 47 | 'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class, 48 | ], 49 | 50 | ]; 51 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | make(Kernel::class); 50 | 51 | $response = tap($kernel->handle( 52 | $request = Request::capture() 53 | ))->send(); 54 | 55 | $kernel->terminate($request, $response); 56 | -------------------------------------------------------------------------------- /tests/Feature/Commands/CheckIfUpcomingStreamsAreLiveCommandTest.php: -------------------------------------------------------------------------------- 1 | upcoming()->create(['scheduled_start_time' => now()->addMinutes(15)]); 20 | Stream::factory()->upcoming()->create(['scheduled_start_time' => now()->addMinutes(20)]); 21 | 22 | // Act & Expect 23 | $this->artisan(CheckIfUpcomingStreamsAreLiveCommand::class) 24 | ->expectsOutput('Fetching 1 stream(s) from API to update their status.') 25 | ->assertExitCode(0); 26 | } 27 | 28 | /** @test */ 29 | public function it_does_not_update_finished_or_live_streams(): void 30 | { 31 | // Arrange 32 | Http::fake(); 33 | Stream::factory()->live()->create(); 34 | Stream::factory()->finished()->create(); 35 | 36 | // Act & Expect 37 | $this->artisan(CheckIfUpcomingStreamsAreLiveCommand::class) 38 | ->expectsOutput('There are no streams to update.') 39 | ->assertExitCode(0); 40 | } 41 | 42 | /** @test */ 43 | public function it_does_not_update_unapproved_streams(): void 44 | { 45 | // Arrange 46 | Http::fake(); 47 | 48 | // Arrange 49 | Stream::factory()->notApproved()->create(['scheduled_start_time' => now()->addMinutes(15)]); 50 | 51 | // Act & Expect 52 | $this->artisan(CheckIfUpcomingStreamsAreLiveCommand::class) 53 | ->expectsOutput('There are no streams to update.') 54 | ->assertExitCode(0); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /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 | 'useTLS' => true, 41 | ], 42 | ], 43 | 44 | 'ably' => [ 45 | 'driver' => 'ably', 46 | 'key' => env('ABLY_KEY'), 47 | ], 48 | 49 | 'redis' => [ 50 | 'driver' => 'redis', 51 | 'connection' => 'default', 52 | ], 53 | 54 | 'log' => [ 55 | 'driver' => 'log', 56 | ], 57 | 58 | 'null' => [ 59 | 'driver' => 'null', 60 | ], 61 | 62 | ], 63 | 64 | ]; 65 | -------------------------------------------------------------------------------- /resources/views/pages/partials/header/preview.blade.php: -------------------------------------------------------------------------------- 1 | @if($upcomingStream) 2 | 35 | 36 | @endif 37 | -------------------------------------------------------------------------------- /resources/views/livewire/stream-list-archive.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 |
7 |
8 | 9 |
10 | @if(count($streams)) 11 |
12 |
13 |
    14 | @foreach ($streams as $stream) 15 | @include('pages.partials.stream-archive') 16 | @endforeach 17 |
18 |
19 |
20 | @else 21 |
22 |
23 |

24 | No streams found. 25 |

26 |
27 |
28 | @endif 29 |
30 | 31 |
32 | {{ $streams->links() }} 33 |
34 |
35 |
36 |
37 | 38 | 39 | @push('scripts') 40 | 48 | @endpush 49 | -------------------------------------------------------------------------------- /app/Actions/Fortify/UpdateUserProfileInformation.php: -------------------------------------------------------------------------------- 1 | ['required', 'string', 'max:255'], 23 | 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], 24 | 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], 25 | ])->validateWithBag('updateProfileInformation'); 26 | 27 | if (isset($input['photo'])) { 28 | $user->updateProfilePhoto($input['photo']); 29 | } 30 | 31 | if ($input['email'] !== $user->email && 32 | $user instanceof MustVerifyEmail) { 33 | $this->updateVerifiedUser($user, $input); 34 | } else { 35 | $user->forceFill([ 36 | 'name' => $input['name'], 37 | 'email' => $input['email'], 38 | ])->save(); 39 | } 40 | } 41 | 42 | /** 43 | * Update the given verified user's profile information. 44 | * 45 | * @param mixed $user 46 | * @param array $input 47 | * @return void 48 | */ 49 | protected function updateVerifiedUser($user, array $input) 50 | { 51 | $user->forceFill([ 52 | 'name' => $input['name'], 53 | 'email' => $input['email'], 54 | 'email_verified_at' => null, 55 | ])->save(); 56 | 57 | $user->sendEmailVerificationNotification(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /resources/lang/vendor/backup/ja/notifications.php: -------------------------------------------------------------------------------- 1 | '例外のメッセージ: :message', 5 | 'exception_trace' => '例外の追跡: :trace', 6 | 'exception_message_title' => '例外のメッセージ', 7 | 'exception_trace_title' => '例外の追跡', 8 | 9 | 'backup_failed_subject' => ':application_name のバックアップに失敗しました。', 10 | 'backup_failed_body' => '重要: :application_name のバックアップ中にエラーが発生しました。', 11 | 12 | 'backup_successful_subject' => ':application_name のバックアップに成功しました。', 13 | 'backup_successful_subject_title' => 'バックアップに成功しました!', 14 | 'backup_successful_body' => '朗報です。ディスク :disk_name へ :application_name のバックアップが成功しました。', 15 | 16 | 'cleanup_failed_subject' => ':application_name のバックアップ削除に失敗しました。', 17 | 'cleanup_failed_body' => ':application_name のバックアップ削除中にエラーが発生しました。', 18 | 19 | 'cleanup_successful_subject' => ':application_name のバックアップ削除に成功しました。', 20 | 'cleanup_successful_subject_title' => 'バックアップ削除に成功しました!', 21 | 'cleanup_successful_body' => 'ディスク :disk_name に保存された :application_name のバックアップ削除に成功しました。', 22 | 23 | 'healthy_backup_found_subject' => 'ディスク :disk_name への :application_name のバックアップは正常です。', 24 | 'healthy_backup_found_subject_title' => ':application_name のバックアップは正常です。', 25 | 'healthy_backup_found_body' => ':application_name へのバックアップは正常です。いい仕事してますね!', 26 | 27 | 'unhealthy_backup_found_subject' => '重要: :application_name のバックアップに異常があります。', 28 | 'unhealthy_backup_found_subject_title' => '重要: :application_name のバックアップに異常があります。 :problem', 29 | 'unhealthy_backup_found_body' => ':disk_name への :application_name のバックアップに異常があります。', 30 | 'unhealthy_backup_found_not_reachable' => 'バックアップ先にアクセスできませんでした。 :error', 31 | 'unhealthy_backup_found_empty' => 'このアプリケーションのバックアップは見つかりませんでした。', 32 | 'unhealthy_backup_found_old' => ':date に保存された直近のバックアップが古すぎます。', 33 | 'unhealthy_backup_found_unknown' => '申し訳ございません。予期せぬエラーです。', 34 | 'unhealthy_backup_found_full' => 'バックアップがディスク容量を圧迫しています。現在の使用量 :disk_usage は、許可された限界値 :disk_limit を超えています。', 35 | ]; 36 | -------------------------------------------------------------------------------- /resources/views/auth/login.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | @if (session('status')) 10 |
11 | {{ session('status') }} 12 |
13 | @endif 14 | 15 |
16 | @csrf 17 | 18 |
19 | 20 | 21 |
22 | 23 |
24 | 25 | 26 |
27 | 28 |
29 | 33 |
34 | 35 |
36 | @if (Route::has('password.request')) 37 | 38 | {{ __('Forgot your password?') }} 39 | 40 | @endif 41 | 42 | 43 | {{ __('Log in') }} 44 | 45 |
46 |
47 |
48 |
49 | -------------------------------------------------------------------------------- /app/Console/Commands/ImportChannelsForStreamsCommand.php: -------------------------------------------------------------------------------- 1 | approved() 21 | ->limit(50) 22 | ->get(); 23 | 24 | if ($streamsWithoutChannel->isEmpty()) { 25 | $this->info('There are no streams without a channel.'); 26 | 27 | return self::SUCCESS; 28 | } 29 | 30 | $this->info("Fetching {$streamsWithoutChannel->count()} stream(s) from API to check for channel."); 31 | 32 | $youTubeResponse = YouTube::videos($streamsWithoutChannel->pluck('youtube_id')); 33 | 34 | $this->info("Found {$youTubeResponse->count()} stream(s) from API."); 35 | 36 | if ($youTubeResponse->isEmpty()) { 37 | $this->info('No channels were imported or updated.'); 38 | 39 | return self::SUCCESS; 40 | } 41 | 42 | $youTubeResponse->each(function(StreamData $streamData) { 43 | // Import new channel 44 | $channelData = YouTube::channel($streamData->channelId); 45 | $channel = Channel::updateOrCreate(['platform_id' => $channelData->platformId], array_merge($channelData->prepareForModel(), ['language_code' => 'en'])); 46 | $stream = Stream::where('youtube_id', $streamData->videoId)->first(); 47 | $stream->update(['channel_id' => $channel->id]); 48 | }); 49 | 50 | $this->info($streamsWithoutChannel->count().' stream channels were updated or imported.'); 51 | 52 | return self::SUCCESS; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /resources/views/components/add-streams-to-calendar.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 6 | Add streams to calendar 7 | 8 | 9 |
10 | 18 | 19 |
31 | 38 |
39 |
40 |
41 | -------------------------------------------------------------------------------- /app/Http/Livewire/SubmitYouTubeLiveStream.php: -------------------------------------------------------------------------------- 1 | 'The YouTube ID field cannot be empty.', 21 | 'youTubeIdOrUrl.unique' => 'This stream was already submitted.', 22 | 'submittedByEmail.required' => 'The Email field cannot be empty.', 23 | ]; 24 | 25 | public function rules(): array 26 | { 27 | return [ 28 | 'youTubeIdOrUrl' => ['required', Rule::unique('streams', 'youtube_id'), new YouTubeRule()], 29 | 'submittedByEmail' => 'required', 30 | ]; 31 | } 32 | 33 | public function render(): View 34 | { 35 | return view('livewire.submit-you-tube-live-stream'); 36 | } 37 | 38 | public function submit(): void 39 | { 40 | $this->youTubeIdOrUrl = $this->determineYoutubeId(); 41 | $this->validate(); 42 | 43 | $action = app(SubmitStreamAction::class); 44 | $action->handle($this->youTubeIdOrUrl, $this->languageCode, $this->submittedByEmail); 45 | 46 | session()->flash('message', 'You successfully submitted your stream. You will receive an email, if it gets approved.'); 47 | $this->reset(['youTubeIdOrUrl', 'languageCode', 'submittedByEmail']); 48 | } 49 | 50 | private function determineYoutubeId(): ?string 51 | { 52 | if (filter_var($this->youTubeIdOrUrl, FILTER_VALIDATE_URL)) { 53 | preg_match("#(?<=v=)[a-zA-Z0-9-]+(?=&)|(?<=v\/)[^&\n]+(?=\?)|(?<=v=)[^&\n]+|(?<=youtu.be/)[^&\n]+#", $this->youTubeIdOrUrl, $matches); 54 | 55 | return $matches[0]; 56 | } 57 | 58 | return $this->youTubeIdOrUrl; 59 | } 60 | } 61 | --------------------------------------------------------------------------------